@rangojs/router 0.17.0 → 0.19.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 (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -64,11 +64,16 @@ Low-level building blocks (all from `@rangojs/router/testing`):
64
64
 
65
65
  ```ts
66
66
  import { it, expect } from "vitest";
67
- import { runInRequestContext } from "@rangojs/router/testing";
68
- import { loginAction } from "../src/actions/login"; // sets a session cookie + flash, then throw redirect("/app")
67
+ import {
68
+ runInRequestContext,
69
+ withLocationStateKey,
70
+ } from "@rangojs/router/testing";
71
+ import { loginAction } from "../src/actions/login"; // sets a session cookie, then throw redirect("/app", { state: [Flash({ text: "Welcome back" })] })
69
72
  import { logoutAction, dismissBannerAction } from "../src/actions/session"; // invalidateClientCache() / keepClientCache()
73
+ import { Flash } from "../src/location-states"; // createLocationState<{ text: string }>({ flash: true })
70
74
 
71
75
  const env = { DB: fakeDb }; // your binding double (see ./bindings.md)
76
+ withLocationStateKey(Flash, "Flash"); // no Vite plugin here: key -> "__rsc_ls_Flash"
72
77
 
73
78
  it("sets the session cookie + flash and redirects", async () => {
74
79
  const { thrown, cookies, locationState } = await runInRequestContext(
@@ -82,7 +87,7 @@ it("sets the session cookie + flash and redirects", async () => {
82
87
  );
83
88
  expect((thrown as Response).headers.get("Location")).toBe("/app"); // redirected
84
89
  expect(cookies.session).toBeDefined(); // cookie set before the throw, no @internal cast
85
- expect(locationState).toEqual({ flash: { text: "Welcome back" } });
90
+ expect(locationState).toEqual({ __rsc_ls_Flash: { text: "Welcome back" } });
86
91
  });
87
92
 
88
93
  it("asserts the client-cache directives an action issued", async () => {
@@ -105,6 +110,7 @@ it("asserts the client-cache directives an action issued", async () => {
105
110
  ## Caveats
106
111
 
107
112
  - The snapshot fires whether `fn` RETURNS or THROWS. A `throw redirect("/app")` on the success path is captured on `thrown` (NOT re-thrown), so no try/catch is needed; assert on `thrown` for a throwing action.
113
+ - Location state needs a key. A `createLocationState()` definition gets it from the Vite plugin, which a unit-test project does not run, and outside production an unkeyed `Flash(value)` throws before `redirect()` runs: `thrown` is that missing-key error, not the redirect. Call `withLocationStateKey(Flash, "Flash")` once per definition; `locationState` then holds `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix; `{ [Flash.__rsc_ls_key]: value }` works for any key).
108
114
  - There is no cookies / headers option. Seed a request cookie by passing a full `Request` with the `Cookie` header (as in the recipe).
109
115
  - An action has no loader context, so `runLoader` is the wrong shape for it even when the action reads cookies or vars; use `runInRequestContext`.
110
116
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
@@ -86,10 +86,13 @@ from `@rangojs/router/theme` in `<head>` instead. It takes the resolved config
86
86
  are rejected with a console warning. Both are typed optional because they only
87
87
  exist when `theme` is configured.
88
88
 
89
+ `ctx.theme` is the visitor's theme cookie, so a handler reads it only on a
90
+ route whose output is rendered per request:
91
+
89
92
  ```typescript
90
93
  import type { Middleware } from "@rangojs/router";
91
94
 
92
- // In a handler
95
+ // In a handler on a route without ppr or cache()
93
96
  path("/settings", (ctx) => {
94
97
  const currentTheme = ctx.theme; // "light" | "dark" | "system" | undefined
95
98
  return <SettingsPage theme={currentTheme} />;
@@ -105,9 +108,45 @@ export const themeFromQuery: Middleware = async (ctx, next) => {
105
108
  };
106
109
  ```
107
110
 
108
- Loaders do not get `ctx.theme` or `ctx.setTheme`. A loader that needs the
109
- theme reads the cookie directly (loaders run fresh on every request, so the
110
- read is safe):
111
+ ### On `ppr` and `cache()` routes
112
+
113
+ A `ppr` shell and a `cache()` entry are shared by every visitor, so a handler
114
+ there must not read the visitor's theme. `ctx.theme` (handler and middleware)
115
+ and `getRequestContext().theme` are guarded like `cookies()`:
116
+
117
+ | Where the theme is read | What happens |
118
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
119
+ | a handler on a `ppr` route | The shell capture is refused (warned once per URL); every request stays a MISS |
120
+ | inside a `cache()` boundary | Throws on a cache miss, like `cookies()` |
121
+ | inside a `"use cache"` function (handler or middleware) | Throws; read it outside and pass it in as an argument |
122
+ | after `ctx.dynamic()` on `ppr` | Allowed: that render is never captured |
123
+ | middleware `ctx.set()` of the theme, read by a handler | Not guarded: the first visitor's theme bakes into the shell or `cache()` entry, like a session object |
124
+ | a route with no `ppr` or `cache()` above it, or middleware | Allowed: rendered per request |
125
+ | a loader with its own `cache()` and no `key()` | The miss fails and stores nothing (#972), like a `cookies()` read there; key the theme |
126
+ | a handler on a document-cached route (`s-maxage`) | Not guarded: the stored document shows that visitor's theme to everyone |
127
+
128
+ The theme getters are read-only and non-enumerable: `{ ...ctx }` and
129
+ `Object.assign({}, ctx)` do not carry `theme`, and assigning it throws.
130
+
131
+ `ctx.setTheme()` throws in a handler on these routes too, like any response
132
+ write there; call it from middleware.
133
+
134
+ Read the theme where it is per request instead:
135
+
136
+ - `useTheme()` in a client component. The `<html>` class is right before
137
+ paint (the theme script sets it). A render that other visitors may be
138
+ served starts `useTheme()` at the no-cookie default (`defaultTheme`),
139
+ whoever rendered it: a `ppr` shell, and a page whose response opted in to
140
+ the document cache before `next()` (#978, see `/document-cache`). A
141
+ visitor with no stored theme sees the default, and one with a stored theme
142
+ sees it after hydration. Any other render, a 404 included, starts at the
143
+ visitor's own theme.
144
+ - A live loader (no `ssr: false`). Loaders do not get `ctx.theme` or
145
+ `ctx.setTheme`; read the cookie directly. A live loader runs on every
146
+ request, HITs included, and on a `ppr` route needs `loading()` or an inline
147
+ `<Suspense>` above its reader. A bake-lane loader (`ssr: false`) runs at
148
+ capture, where this `cookies()` read refuses the capture, and so does a
149
+ loader a handler awaits with `ctx.use()`.
111
150
 
112
151
  ```typescript
113
152
  import { cookies, createLoader } from "@rangojs/router";
@@ -118,6 +157,19 @@ export const ThemedLoader = createLoader(async () => {
118
157
  });
119
158
  ```
120
159
 
160
+ If you bind it with its own `cache()`, the entry is shared across users, so the
161
+ miss fails unless it has a `key()`. Any `key()` switches that check off, so
162
+ make it include the theme:
163
+
164
+ ```typescript
165
+ loader(ThemedLoader, () => [
166
+ cache({
167
+ ttl: 300,
168
+ key: () => `themed:${cookies().get("theme")?.value ?? "system"}`,
169
+ }),
170
+ ]);
171
+ ```
172
+
121
173
  ## Client
122
174
 
123
175
  ```tsx
@@ -103,7 +103,8 @@ interface PaginationData {
103
103
  }
104
104
  export const Pagination = createVar<PaginationData>();
105
105
 
106
- // Non-cacheable var — reading inside cache() or "use cache" throws at runtime
106
+ // Non-cacheable var — reading inside cache(), "use cache", or a PPR shell
107
+ // capture throws at runtime
107
108
  interface SessionData {
108
109
  userId: string;
109
110
  }
@@ -115,7 +116,14 @@ export const Session = createVar<SessionData>({ cache: false });
115
116
  Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
116
117
  marks a specific write as non-cacheable even if the var itself is cacheable.
117
118
  "Least cacheable wins" — if either says `cache: false`, the value throws on
118
- read inside `cache()` or `"use cache"`.
119
+ read inside `cache()` or `"use cache"`. On a `ppr` route the read also throws
120
+ anywhere the shell capture waits for it (a handler, a promise it passes or
121
+ pushes, an async server component, an `ssr: false` loader, a loader a handler
122
+ awaits) and refuses the capture: the route keeps rendering without a shell,
123
+ and a warning fires once per key. A loader without `ssr: false` that no
124
+ handler awaits may read it, unless it is bound with its own `cache()` and no
125
+ `key()`: that miss fails, since the entry is shared (`/loader` → "Cache
126
+ Key").
119
127
 
120
128
  ### Producer (handler, layout, or middleware)
121
129
 
@@ -331,3 +339,11 @@ the value at click time; `ctx.setLocationState(entry)` attaches state from a
331
339
  handler; `redirect(url, { state })` carries it through a redirect; and
332
340
  `ProductPreview.read()` / `.write(value)` / `.delete()` access the current
333
341
  history entry outside React (client-only, non-reactive).
342
+ `ProductPreview.read(location)` reads the slot from any `{ state }` snapshot,
343
+ such as a `transition({ when })` context's `from` / `to`, and works without
344
+ `window`.
345
+
346
+ The `state` option on `<Link>`, `router.push()` and `router.replace()` is typed
347
+ `HistoryState`: an array of called entries (`[ProductPreview({ ... })]`) or
348
+ plain structured-clone data. A bare entry (`state: ProductPreview({ ... })`) or
349
+ an uncalled definition (`state: [ProductPreview]`) is a compile error.
@@ -142,6 +142,11 @@ Different functions always produce different cache keys, even for the same route
142
142
  This is important for intercepted routes -- the path handler and intercept handler
143
143
  each have their own `functionId` and therefore their own cache entries.
144
144
 
145
+ The key is only what the function declares: its id and arguments. A call made
146
+ inside a route `cache()` with a `key()` does not inherit that key, so a value
147
+ the route partitions by (a tier header, a session) must be passed in as an
148
+ argument. The same holds for a loader's own `cache()` (`/loader` → "Cache Key").
149
+
145
150
  ### Route context is folded into the key
146
151
 
147
152
  The tainted `ctx` object is excluded from arg serialization (see below), but
@@ -224,13 +229,32 @@ export const ProductLoader = createLoader(async (ctx) => getProduct(ctx));
224
229
  per-request values (cookies, headers) are not reflected in the cache key. Without
225
230
  this guard, one user's data would be served to another.
226
231
 
232
+ The raw reads throw the same way: `ctx.request.headers` on any `ctx` passed in
233
+ (handler, loader, middleware; it is one `Request` object, also
234
+ `getRequestContext().request`), and `getRequestContext().cookie()` /
235
+ `.cookies()`. A `ctx` or a `Request` argument keys the entry by its route and
236
+ URL only, never by its headers.
237
+
227
238
  Extract the value before the cached function and pass it as an argument:
228
239
 
229
240
  ```typescript
230
241
  const locale = cookies().get("locale")?.value ?? "en";
231
242
  const data = await getCachedData(locale); // locale is now in the cache key
243
+
244
+ // the same for a header
245
+ const language = ctx.request.headers.get("accept-language") ?? "en";
246
+ const greeting = await getGreeting(language);
232
247
  ```
233
248
 
249
+ Building the key from a `Request` argument reads only its URL.
250
+ `ctx.request.clone()` is guarded like `ctx.request.headers`. One gap remains:
251
+ `fetch(ctx.request)` and `new Request(ctx.request)` don't throw, and the guard
252
+ can't see through them. The fetch forwards the visitor's `Cookie` and
253
+ `Authorization`, so its response is per visitor, and the copy's headers are
254
+ the visitor's; either one stored in the entry serves one visitor's data to
255
+ the next. Don't make them in a cached body: fetch outside and pass the
256
+ result in, or pass in the values the fetch depends on.
257
+
234
258
  `ctx.get()` of a **non-cacheable variable** (`createVar({ cache: false })`, or
235
259
  a value written with `ctx.set(key, value, { cache: false })`) throws the same
236
260
  way, whether it goes through `getRequestContext().get()` or a `ctx` passed in
@@ -254,14 +278,20 @@ Ordinary (cacheable) variables stay readable. Calling the cached function from
254
278
  a loader does not exempt it: the loader re-runs on every request, but the
255
279
  cached body does not.
256
280
 
257
- The non-cacheable variable guard does not reach into LOADER bodies consumed
258
- inside the cached function (`await ctx.use(loader)`): loaders always run
259
- fresh, so their variable reads are exempt — but the CONSUMED VALUE is captured
260
- into the shared cache entry like any other computed data. (`cookies()` and
261
- `headers()` still throw there: that guard follows the cached body's whole
262
- async chain, loaders included.) Same rule across `cache()` and the PPR shell:
263
- handler/cached-scope consumption = baked copy, client-side `useLoader` = live
264
- (the consumption-lane rule, `/rango` → Invariants).
281
+ A LOADER body consumed inside the cached function (`await ctx.use(loader)`)
282
+ is part of that body: its value is captured into the shared cache entry like
283
+ any other computed data, so a `{ cache: false }` variable read there throws,
284
+ exactly as `cookies()`, `headers()`, `ctx.request.headers` and a theme read do
285
+ (one guard follows the
286
+ cached body's whole async chain, loaders included). The check runs only when
287
+ the loader body starts inside the cached function: a loader something else in
288
+ the request already started (a handler's `ctx.use()`, a route `loader()`)
289
+ returns its memoized value without it, and that value is still stored in the
290
+ entry. Read the value outside and pass it in as an argument. Handler/cached-scope consumption = baked copy,
291
+ client-side `useLoader` = live (the consumption-lane rule, `/rango` →
292
+ Invariants). Under a route `cache()` a loader body's reads stay allowed (the
293
+ route entry never stores a loader's value); during a PPR capture they refuse
294
+ the capture, a handler-consumed loader's included.
265
295
 
266
296
  ### Side-Effect Guards
267
297
 
@@ -504,20 +534,55 @@ built into the server bundle, see `/prerender`.)
504
534
  Entries are tagged by the profile's `tags` plus any runtime `cacheTag(...tags)`
505
535
  calls in the function body. `cacheTag` has two forms: inside a `"use cache"`
506
536
  function it tags that entry; called during a request render outside
507
- `"use cache"` it tags the request's `ppr` shell / document-cache entry instead
508
- (see `/caching` → "Tag-Based Invalidation"). All built-in stores
509
- (`MemorySegmentCacheStore`, `CFCacheStore`, `VercelCacheStore`) index by tag.
510
- Invalidate on demand with `updateTag(...tags)` (awaitable,
511
- read-your-own-writes; for server actions) or `revalidateTag(...tags)`
512
- (background, non-blocking; for route handlers/webhooks). Both hard-purge; the
513
- difference is awaitability, not stale-serving. For `CFCacheStore`, cross-colo
514
- invalidation needs a `kv` namespace (markers live in that same namespace) or
515
- `tagPurge`. The separate `revalidate()` export is the client-update axis (which
516
- segments re-render on a navigation or action), not a cache bust.
517
-
518
- A `"use cache"` entry's tags do not propagate to an enclosing `cache()` segment
519
- entry: invalidating them re-runs the function on the next miss of the outer
520
- boundary, not before (see `/cache-guide` → "Nesting rule").
537
+ `"use cache"` it tags what the render stores: the enclosing route `cache()`
538
+ entry, a cached loader's own entry when called in its body, and the `ppr`
539
+ shell or document-cache entry built from them (on a `ppr` route, only when
540
+ shell material calls it). See `/caching` → "Tag-Based Invalidation". All
541
+ built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`,
542
+ `VercelCacheStore`) index by tag. Invalidate on demand with
543
+ `updateTag(...tags)` (awaitable; for server actions) or
544
+ `revalidateTag(...tags)` (background, non-blocking; for route
545
+ handlers/webhooks). Both evict rather than mark stale, and the request that
546
+ calls either reads its own writes; `updateTag()` also waits for the durable
547
+ write. PPR shell reads on `CFCacheStore`/`VercelCacheStore` go through
548
+ per-isolate memos, which the invalidating user skips via the fresh-reads
549
+ cookie. For `CFCacheStore`, cross-colo invalidation needs a `kv` namespace
550
+ (markers live in that same namespace) or `tagPurge`. The separate
551
+ `revalidate()` export is the client-update control (which segments re-render
552
+ on a navigation or action), not a cache bust.
553
+
554
+ A route `cache()` entry stores the tags of the `"use cache"` reads inside it,
555
+ so `updateTag()` of one evicts the enclosing entry and any `ppr` shell or
556
+ document built from it. A loader's own `cache()` entry does the same for the
557
+ reads in its body, and a `"use cache"` function for the `"use cache"`
558
+ functions it calls: the outer entry bakes the inner value, so it carries the
559
+ inner tags, whether the inner call ran, was a hit, or joined another call's execution. That includes calls made
560
+ by the server components a cached component returns, which render when the
561
+ value is stored.
562
+
563
+ ```typescript
564
+ async function getStock(sku: string) {
565
+ "use cache";
566
+ cacheTag("stock");
567
+ return db.stock(sku);
568
+ }
569
+
570
+ async function getProductCard(sku: string) {
571
+ "use cache";
572
+ return { sku, stock: await getStock(sku) };
573
+ }
574
+
575
+ // getProductCard's entry is tagged "stock": updateTag("stock") evicts both.
576
+ ```
577
+
578
+ A call that started before `updateTag()`/`revalidateTag()` of one of its tags
579
+ returns what it read but does not store it, in any request: written after the
580
+ invalidation, the old value would be served as newer than it. The next call
581
+ runs the function again. The same holds for a stale entry's background
582
+ refresh, a loader's own `cache()`, route `cache()` entries and the document
583
+ cache. Another isolate's invalidation is caught through the store's markers:
584
+ `CFCacheStore` with KV, `VercelCacheStore`. On a KV-less `CFCacheStore` only
585
+ this isolate's invalidations are caught; ttl+swr bounds the rest.
521
586
 
522
587
  ## Interaction with Other Caching
523
588
 
@@ -531,7 +596,10 @@ boundary, not before (see `/cache-guide` → "Nesting rule").
531
596
  | `Prerender()` | Route segment tree | Build-time | Pre-render known params, optional live fallback |
532
597
 
533
598
  Inside a `ppr` shell, a `"use cache"` value that renders as shell material is
534
- pinned at capture time for the life of that shell (see `/ppr` → Pitfalls).
599
+ pinned at capture time for the life of that shell (see `/ppr` → Pitfalls). A
600
+ hole's own read (in a loader without `ssr: false` under `loading()` or an
601
+ inline `<Suspense>`) always returns the store's current entry, never the
602
+ shell's copy, even when a loader the capture ran read the same entry.
535
603
 
536
604
  ## Dev Mode and tests
537
605
 
@@ -89,12 +89,24 @@ import { getCache, waitUntil } from "@vercel/functions";
89
89
 
90
90
  const defaults = { ttl: 60, swr: 300 };
91
91
  const memoryStore = new MemorySegmentCacheStore({ defaults });
92
+ // One handle per process: getCache() resolves the platform cache on every
93
+ // call, and VercelCacheStore keeps its PPR shell and tag-marker memos per
94
+ // handle. After updateTag(), the same process misses at once, the rest of the
95
+ // region once its marker memo refreshes (memo.markerMaxStaleMs, default 2000),
96
+ // and the mutating user at once (the fresh-reads cookie skips the memos).
97
+ // Another region serves a memoized shell until memo.shellMs (default 2000)
98
+ // passes, since the tag markers are regional and only expireTag is global. A
99
+ // platform expireTag issued outside rango writes no marker, so every memo
100
+ // serves the shell until its window passes.
101
+ const runtimeCache = process.env.VERCEL
102
+ ? getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })
103
+ : undefined;
92
104
 
93
105
  function resolveCache() {
94
- if (process.env.VERCEL) {
106
+ if (runtimeCache) {
95
107
  return {
96
108
  store: new VercelCacheStore({
97
- cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
109
+ cache: runtimeCache,
98
110
  waitUntil,
99
111
  defaults,
100
112
  }),
@@ -227,8 +227,8 @@ interface TransitionConfig {
227
227
  default?: string | Record<string, string>; // fallback for any phase
228
228
  name?: string; // explicit view-transition-name
229
229
  viewTransition?: "auto" | false; // boundary opt-out (see below)
230
- // Conditional server-side gate. PPR routes run it before route handlers and
231
- // on every replay; other routes run it after handlers on fresh resolution.
230
+ // Browser predicate, once per navigation: false commits it urgently with no
231
+ // view transition. See "Conditional transitions".
232
232
  when?: (ctx: TransitionWhenContext) => boolean;
233
233
  }
234
234
  ```
@@ -240,47 +240,162 @@ interface TransitionConfig {
240
240
 
241
241
  ## Conditional transitions (`when`)
242
242
 
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).
243
+ `transition({ when })` decides, per navigation, whether the navigation holds.
244
+ It is a **browser** predicate: it runs once per navigation, at the first
245
+ commit that presents the destination, and never on the server, on the
246
+ document load, or on progressive-enhancement paths. Return `false` and the
247
+ navigation commits urgently: the `loading()` skeleton streams and no view
248
+ transition runs.
249
+
250
+ Write it inline in `urls()`. The Vite plugin hoists the literal into a
251
+ `"use client"` module, and the route carries it to the browser as a client
252
+ reference. The hoist applies to the router's `transition`: the `urls()`
253
+ helper (renamed or not) or `transition` imported from `@rangojs/router`,
254
+ with the function written directly as the `when` value (an arrow, a
255
+ `function`, or a `when() {}` method; `as` / `satisfies` wrappers are
256
+ fine). A config built elsewhere, such as `transition(cfg)`, is not hoisted:
257
+ export that predicate from a `"use client"` module instead.
244
258
 
245
- Timing follows the route's rendering contract:
259
+ ```ts
260
+ // Hold unless the navigation leaves the "b" tab (the navigation SOURCE):
261
+ transition({ when: ({ from }) => from.params.tab !== "b" });
262
+
263
+ // Hold only for forward navigations inside /list:
264
+ transition({
265
+ when: ({ kind, to }) => kind !== "pop" && to.url.pathname.startsWith("/list"),
266
+ });
246
267
 
247
- - On an ordinary route it runs after the route handler during fresh resolution, so `get()` can read handler- and middleware-set context. Cache/prerender hits replay the stored decision.
248
- - On a `ppr` route it is automatically hoisted before route handlers and runs on every match, including runtime-cache, prerender, document-shell, and partial-navigation replay. It can read URL/params/action metadata, `env`, and middleware-set context, but not values set by route handlers. This is what keeps the handler-free PPR fast path available without a second API.
268
+ // Hold only after a specific action revalidated the route:
269
+ transition({ when: ({ isAction }) => isAction(addToCart) });
270
+ ```
249
271
 
250
- Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for request-context reads:
272
+ For a predicate you share between routes or unit-test, export it from a
273
+ `"use client"` module and import it into the urls file:
251
274
 
252
275
  ```ts
276
+ // src/transitions.ts
277
+ "use client";
253
278
  import type { TransitionWhenContext } from "@rangojs/router";
279
+ import { Slide } from "./location-states.js";
254
280
 
255
- // Ordinary route: hold only when the handler marked this request:
256
- transition({ when: (ctx) => ctx.get(KeepScroll) === true });
281
+ export function slideWhen({ to }: TransitionWhenContext): boolean {
282
+ return Slide.read(to)?.animate !== false;
283
+ }
257
284
 
258
- // Hold only when arriving from a specific page (the navigation SOURCE):
259
- transition({
260
- when: ({ currentUrl }) => currentUrl?.pathname.startsWith("/list") === true,
261
- });
262
- transition({ when: ({ fromRouteName }) => fromRouteName === "products.list" });
285
+ // src/urls.tsx
286
+ import { slideWhen } from "./transitions.js";
287
+ path("/photos/:id", PhotoPage, { name: "photo" }, () => [
288
+ transition({ default: "slide", when: slideWhen }),
289
+ ]);
263
290
 
264
- // Hold only after a specific action revalidated the route:
265
- transition({
266
- when: ({ actionId }) => actionId === "src/actions/cart.ts#addToCart",
267
- });
291
+ // A Link that opts this navigation out through location state:
292
+ <Link to="/photos/2" state={[Slide({ animate: false })]}>Next</Link>;
268
293
  ```
269
294
 
270
- | field | meaning | populated |
271
- | ------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------- |
272
- | `currentUrl` / `currentParams` / `fromRouteName` | navigation **source** | soft nav + action-success; `undefined` on initial full load and action/PE error paths |
273
- | `nextUrl` / `nextParams` | navigation **target** | always |
274
- | `toRouteName` (and `fromRouteName`) | route **name** | when the route is named (undefined for unnamed/auto-generated) |
275
- | `actionId` / `actionUrl` / `actionResult` / `formData` | the server action that triggered this render | action-triggered renders only |
276
- | `method` | `"GET"` (nav) / `"POST"` (action) | always |
277
- | `get` / `env` | read request vars + app env | always; PPR timing exposes middleware vars, not handler writes |
295
+ Predicate modules (a `"use client"` export, or a hoisted inline literal) load
296
+ with the client entry chunk, because the router must be able to call them at
297
+ the first navigation. Keep them small: type imports and location-state
298
+ definitions only, and nothing that touches `window` at module scope (the
299
+ module is also evaluated during SSR).
300
+
301
+ The rule is "a predicate runs where its effect lands": the hold is a browser
302
+ commit decision, so the predicate runs in the browser. An inline literal may
303
+ reference module-level imports only when they are client-safe (they are
304
+ re-emitted into the hoisted module); a free server binding (a `const` in the
305
+ urls file, a handler variable, a component used as JSX) or a server-only
306
+ import is a build error that names it, and so are `import()`, `import.meta`
307
+ other than `import.meta.env`, `this` / `arguments` of the enclosing
308
+ function, and a `get when()` accessor. A server function passed by name, or a non-function, fails dev
309
+ startup, the build and HMR re-discovery with the route name and pattern.
278
310
 
279
- A predicate that throws is reported to `router.onError` (phase `"rendering"`) and treated as no-hold (conservative).
311
+ ### Context
280
312
 
281
- **Same-route content-holds need the transition present on the FIRST render.** The same-route hold works by giving the route a param-agnostic key so a param change reconciles instead of remounting — but that key is established when the route first mounts. A source gate that returns `false` on the initial full load (where `currentUrl`/`currentParams`/`fromRouteName` are undefined) drops the transition before the route mounts, so the route mounts _outside_ a transition scope and **every** later same-route param nav remounts (flashing the skeleton) regardless of what the gate decides on those navs. Write source gates so they hold when there is no source — e.g. `({ currentParams }) => currentParams?.tab !== "raw"` (true on the initial load) rather than `=== "details"` (false on the initial load) — when the same-route content-hold must engage. This only affects same-route param navigations; action-only or cross-route gating is unaffected (no shared param key is in play).
313
+ ```ts
314
+ interface RouteLocation {
315
+ url: URL;
316
+ params: Record<string, string>;
317
+ routeName: string | undefined;
318
+ state: unknown; // the entry's history.state; read slots with Def.read(location)
319
+ }
282
320
 
283
- **Prefetch / cache caveat.** A **prefetched** navigation still decides at prefetch time — `currentUrl`/`currentParams`/`fromRouteName` reflect the page the prefetch fired from, not necessarily the click-time source. Non-PPR `cache()`/prerender hits also replay the stored transition without rerunning the predicate. PPR routes rerun it on the server for each cache/prerender/PPR match, but a completed browser prefetch still carries its earlier Flight decision. If the exact click-time source matters, source-scope the prefetch (`<Link prefetchKey=":source">`).
321
+ interface TransitionWhenContext {
322
+ kind: "push" | "replace" | "pop" | "action" | "revalidate";
323
+ from: RouteLocation; // the committed location being left
324
+ to: RouteLocation; // the destination; === from for "action" and "revalidate"
325
+ isAction: IsActionFn; // the revalidate() matcher; false off-action
326
+ action?: { id; formData; result; error }; // kind "action" only
327
+ }
328
+ ```
329
+
330
+ | `kind` | started by | `to.state` |
331
+ | -------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
332
+ | `"push"` | `Link`, `router.push()` | the pushed state plus server `ctx.setLocationState()` (handler, middleware, action) |
333
+ | `"replace"` | `Link replace`, `router.replace()` | same as push |
334
+ | `"pop"` | back/forward | the target entry's stored state |
335
+ | `"action"` | a server action's revalidation, error boundary incl. | the current entry's state plus action-set state (none on the error lane: an error response sets no location state); `action.*` set |
336
+ | `"revalidate"` | `router.refresh()`, a stale entry's revalidation | the current entry's state |
337
+
338
+ - `Def.read(location)` reads a location-state slot from `from` or `to`
339
+ without clearing flash state.
340
+ - Middleware-set location state reaches `to.state` on a PPR replay HIT too
341
+ (middleware runs on every HIT); a route handler's does not, because a HIT
342
+ runs no handler. Set it from middleware when a `ppr` route decides on it.
343
+ - A `clientUrls()` cross-route navigation decides at its optimistic swap,
344
+ before the server responds, so its `to.state` holds only the pushed
345
+ state.
346
+ - On `"pop"`, `from.state` and `from.routeName` come from the router's
347
+ per-history-entry memory; after a reload they are `undefined`.
348
+ - `get()`, `env` and handler state do not exist in the browser.
349
+
350
+ ### Combination and `false`
351
+
352
+ - The navigation holds only when **every** committed segment's `when`
353
+ returns true, kept or re-sent: a layout the navigation keeps still gates
354
+ it ([#989](https://github.com/rangojs/rango/issues/989)). The same function
355
+ on several segments is called once.
356
+ - `false` makes the whole navigation urgent, so no segment animates. Every
357
+ `<ViewTransition>` stays in the tree with its classes set to `"none"`, so a
358
+ same-route navigation that flips `true -> false -> true` never remounts
359
+ the route or loses its state
360
+ ([#995](https://github.com/rangojs/rango/issues/995)).
361
+ - That holds for every kind, actions and revalidations included: a gated-off
362
+ action or revalidation commit is urgent too, so a segment it re-renders
363
+ streams its `loading()`. The router commits an action's result after the
364
+ response arrives, outside the transition React opens for a form action or
365
+ `useActionState` call, so React does not hold it either.
366
+ - A predicate that throws counts as `false` and is logged with
367
+ `console.error` (the conservative default: no hold).
368
+ - Intercept (modal) commits skip the evaluation, including a back/forward
369
+ that opens or closes a modal.
370
+ - A stale revalidation the user navigated away from before it landed is
371
+ discarded without calling the predicate.
372
+ - Intercept `from` differs: while a modal is open, an intercept selector's
373
+ `from` is the page under the modal (see the intercept skill); here it is
374
+ always the location on screen.
375
+ - In a `clientUrls()` group, `false` also opts a same-route navigation out of
376
+ the group's default hold.
377
+
378
+ `kind` lines up with the class keys in "Direction-aware transitions": a
379
+ `"pop"` restored from the history cache animates with `"navigation-back"`
380
+ (a pop that refetches uses `"navigation"`); `"push"`, `"replace"` and a
381
+ `router.refresh()` (`"revalidate"`) use `"navigation"`; a stale entry's
382
+ revalidation (`"revalidate"`) and an action's refetch (`"action"`) use
383
+ `"action"`. An action's own commit (`useActionState`, a form action) carries
384
+ no transition type (see the note under "Direction-aware transitions").
385
+
386
+ ### Opting out per navigation
387
+
388
+ `router.push(url, { transition: false })`, `router.replace(url, {
389
+ transition: false })` and `<Link transition={false}>` present one
390
+ navigation the way `when` returning `false` does, without calling any
391
+ predicate. Back/forward, actions and revalidation have no per-navigation
392
+ options and are unaffected.
393
+
394
+ ```tsx
395
+ <Link to="/photos/2" transition={false}>
396
+ Jump without the slide
397
+ </Link>
398
+ ```
284
399
 
285
400
  ## Opting out of the router boundary (place your own `<ViewTransition>`)
286
401
 
@@ -361,6 +476,12 @@ await expect(page$).toBeVisible();
361
476
  Waiting for `toHaveCount(1)` first is not enough: the count passes through 1
362
477
  before the second host is inserted.
363
478
 
479
+ Unit-test a `when` with `runTransitionWhen(when, { kind, from, to, action })`
480
+ from `@rangojs/router/testing`, and a route's hold with `renderRoute`:
481
+ `router.navigate()` decides with `kind: "push"`, `router.refresh()` with
482
+ `kind: "revalidate"`. Pop, action and `clientUrls()` optimistic-swap
483
+ decisions are e2e territory. See the testing skill, `predicates.md`.
484
+
364
485
  ## Recommendations
365
486
 
366
487
  **Put `<ParallelOutlet />` in layouts, not routes.** A route-level `transition` wraps the route component itself, so a `<ParallelOutlet />` rendered directly inside that route component remains inside the route VT subtree — modal opens on a route with a parallel outlet _will_ trigger the route's VT walker. The narrowing fix only applies at layout boundaries. If you combine intercept modals with route-level transitions, mount the slot one level up in a layout.
@@ -10,10 +10,14 @@ import type {
10
10
  Handler,
11
11
  MiddlewareContext,
12
12
  ResponseHandlerContext,
13
+ RouteLocation,
13
14
  RouteParams,
14
15
  RouteSearchParams,
16
+ ServerRouteLocation,
17
+ TransitionWhenFn,
15
18
  } from "../index.js";
16
19
  import type { DefaultRouteName } from "../types/global-namespace.js";
20
+ import type { InterceptWhenFn } from "../server/context.js";
17
21
  import { href } from "../href-client.js";
18
22
  import type { Money, TestBindings } from "./augment.js";
19
23
 
@@ -69,6 +73,23 @@ type _routeName = Expect<
69
73
  Equal<DefaultRouteName, "home" | "blog.post" | "search">
70
74
  >;
71
75
 
76
+ // transition({ when }) / intercept({ when }) locations carry the public route
77
+ // name (internal names never reach them), typed like every routeName.
78
+ type _locationRouteName = Expect<
79
+ Equal<RouteLocation["routeName"], DefaultRouteName | undefined>
80
+ >;
81
+ type _serverLocationRouteName = Expect<
82
+ Equal<ServerRouteLocation["routeName"], DefaultRouteName | undefined>
83
+ >;
84
+ const transitionWhenTypo: TransitionWhenFn = ({ to }) =>
85
+ // @ts-expect-error - "blog.pots" is not a generated route name
86
+ to.routeName === "blog.pots";
87
+ const interceptWhenTypo: InterceptWhenFn = ({ from }) =>
88
+ // @ts-expect-error - "hom" is not a generated route name
89
+ from.routeName === "hom";
90
+ void transitionWhenTypo;
91
+ void interceptWhenTypo;
92
+
72
93
  // RouteParams / RouteSearchParams resolve from the generated map with no
73
94
  // explicit route map argument.
74
95
  type _params = Expect<Equal<RouteParams<"blog.post">, { slug: string }>>;
@@ -131,6 +152,8 @@ type _flightOverride = Expect<Equal<Rango.FlightSerialize<Money>, number>>;
131
152
  // Reference the top-level assertion aliases so they are unambiguously evaluated.
132
153
  export type _Assertions = [
133
154
  _routeName,
155
+ _locationRouteName,
156
+ _serverLocationRouteName,
134
157
  _params,
135
158
  _search,
136
159
  _orderWireByPattern,