@rangojs/router 0.18.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 (244) hide show
  1. package/dist/types/browser/event-controller.d.ts +3 -3
  2. package/dist/types/browser/history-state.d.ts +2 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  4. package/dist/types/browser/partial-update.d.ts +16 -1
  5. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  6. package/dist/types/browser/react/Link.d.ts +19 -9
  7. package/dist/types/browser/react/context.d.ts +2 -2
  8. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  9. package/dist/types/browser/react/location-state.d.ts +1 -1
  10. package/dist/types/browser/transition-when.d.ts +80 -0
  11. package/dist/types/browser/types.d.ts +104 -9
  12. package/dist/types/build/generate-manifest.d.ts +8 -0
  13. package/dist/types/cache/background-task.d.ts +24 -2
  14. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +44 -13
  16. package/dist/types/cache/cache-policy.d.ts +1 -1
  17. package/dist/types/cache/cache-runtime.d.ts +7 -0
  18. package/dist/types/cache/cache-scope.d.ts +117 -35
  19. package/dist/types/cache/cache-tag.d.ts +24 -4
  20. package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
  21. package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
  22. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
  23. package/dist/types/cache/document-cache.d.ts +10 -0
  24. package/dist/types/cache/handle-snapshot.d.ts +40 -8
  25. package/dist/types/cache/invalidation-order.d.ts +23 -0
  26. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +88 -141
  28. package/dist/types/cache/tag-invalidation.d.ts +38 -14
  29. package/dist/types/cache/types.d.ts +68 -61
  30. package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
  31. package/dist/types/client-urls/navigation.d.ts +28 -2
  32. package/dist/types/client-urls/server-projection.d.ts +5 -2
  33. package/dist/types/client-urls/types.d.ts +5 -5
  34. package/dist/types/client.d.ts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.rsc.d.ts +1 -1
  37. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  38. package/dist/types/route-name.d.ts +7 -0
  39. package/dist/types/router/error-handling.d.ts +1 -1
  40. package/dist/types/router/intercept-resolution.d.ts +2 -0
  41. package/dist/types/router/match-context.d.ts +5 -0
  42. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  43. package/dist/types/router/router-options.d.ts +4 -1
  44. package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
  45. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  46. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
  47. package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
  48. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  49. package/dist/types/router/transition-when.d.ts +24 -10
  50. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  51. package/dist/types/rsc/full-payload.d.ts +14 -7
  52. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  53. package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
  54. package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
  55. package/dist/types/rsc/shell-capture.d.ts +78 -81
  56. package/dist/types/rsc/shell-serve.d.ts +19 -48
  57. package/dist/types/rsc/types.d.ts +2 -0
  58. package/dist/types/segment-system.d.ts +7 -0
  59. package/dist/types/server/context.d.ts +89 -61
  60. package/dist/types/server/cookie-store.d.ts +30 -18
  61. package/dist/types/server/handle-store.d.ts +52 -1
  62. package/dist/types/server/request-context.d.ts +66 -45
  63. package/dist/types/server/request-headers.d.ts +44 -0
  64. package/dist/types/server.d.ts +1 -0
  65. package/dist/types/testing/e2e/index.d.ts +1 -1
  66. package/dist/types/testing/index.d.ts +3 -2
  67. package/dist/types/testing/internal/action-id.d.ts +7 -0
  68. package/dist/types/testing/location-state-key.d.ts +24 -0
  69. package/dist/types/testing/render-route.d.ts +17 -1
  70. package/dist/types/testing/run-transition-when.d.ts +44 -69
  71. package/dist/types/testing/serve-shell-request.d.ts +7 -6
  72. package/dist/types/testing/shell-status.d.ts +34 -22
  73. package/dist/types/transition-when-ref.d.ts +79 -0
  74. package/dist/types/types/cache-types.d.ts +15 -3
  75. package/dist/types/types/index.d.ts +1 -1
  76. package/dist/types/types/segments.d.ts +86 -71
  77. package/dist/types/urls/path-helper-types.d.ts +3 -4
  78. package/dist/types/urls/pattern-types.d.ts +3 -4
  79. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  80. package/dist/vite/index.js +960 -97
  81. package/package.json +1 -1
  82. package/skills/cache-guide/SKILL.md +76 -49
  83. package/skills/caching/SKILL.md +167 -69
  84. package/skills/client-urls/SKILL.md +1 -1
  85. package/skills/comparison/references/framework-comparison.md +8 -5
  86. package/skills/document-cache/SKILL.md +24 -0
  87. package/skills/hooks/SKILL.md +3 -1
  88. package/skills/hooks/navigation.md +23 -2
  89. package/skills/hooks/state.md +98 -6
  90. package/skills/intercept/SKILL.md +29 -12
  91. package/skills/loader/SKILL.md +52 -21
  92. package/skills/middleware/SKILL.md +18 -0
  93. package/skills/migrate-nextjs/SKILL.md +3 -3
  94. package/skills/observability/SKILL.md +6 -5
  95. package/skills/parallel/SKILL.md +1 -1
  96. package/skills/ppr/SKILL.md +129 -77
  97. package/skills/prerender/SKILL.md +3 -3
  98. package/skills/rango/SKILL.md +25 -6
  99. package/skills/route/SKILL.md +2 -0
  100. package/skills/testing/SKILL.md +27 -25
  101. package/skills/testing/cache-prerender.md +9 -9
  102. package/skills/testing/client-components.md +45 -3
  103. package/skills/testing/loader.md +3 -2
  104. package/skills/testing/middleware.md +1 -0
  105. package/skills/testing/predicates.md +49 -49
  106. package/skills/testing/render-handler.md +1 -0
  107. package/skills/testing/serve-shell-request.md +14 -0
  108. package/skills/testing/server-actions.md +9 -3
  109. package/skills/theme/SKILL.md +8 -4
  110. package/skills/typesafety/env-and-bindings.md +8 -0
  111. package/skills/use-cache/SKILL.md +64 -19
  112. package/skills/view-transitions/SKILL.md +150 -29
  113. package/src/__augment-tests__/augmented.check.ts +23 -0
  114. package/src/browser/event-controller.ts +4 -4
  115. package/src/browser/history-state.ts +52 -1
  116. package/src/browser/link-interceptor.ts +4 -0
  117. package/src/browser/navigation-bridge.ts +77 -18
  118. package/src/browser/navigation-client.ts +4 -3
  119. package/src/browser/navigation-store.ts +43 -1
  120. package/src/browser/navigation-transaction.ts +31 -11
  121. package/src/browser/partial-update.ts +146 -35
  122. package/src/browser/prefetch/cache.ts +6 -6
  123. package/src/browser/prefetch/fetch.ts +2 -2
  124. package/src/browser/react/Link.tsx +38 -33
  125. package/src/browser/react/NavigationProvider.tsx +2 -2
  126. package/src/browser/react/context.ts +2 -2
  127. package/src/browser/react/location-state-shared.ts +241 -49
  128. package/src/browser/react/location-state.ts +5 -2
  129. package/src/browser/rsc-router.tsx +4 -0
  130. package/src/browser/server-action-bridge.ts +84 -15
  131. package/src/browser/transition-when.ts +205 -0
  132. package/src/browser/types.ts +124 -10
  133. package/src/build/generate-manifest.ts +77 -1
  134. package/src/cache/background-task.ts +55 -2
  135. package/src/cache/cache-exec-scope.ts +48 -1
  136. package/src/cache/cache-key-utils.ts +56 -16
  137. package/src/cache/cache-policy.ts +9 -4
  138. package/src/cache/cache-runtime.ts +197 -144
  139. package/src/cache/cache-scope.ts +304 -112
  140. package/src/cache/cache-tag.ts +105 -13
  141. package/src/cache/cf/cf-cache-store.ts +152 -76
  142. package/src/cache/cf/cf-shell-frame.ts +2 -2
  143. package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
  144. package/src/cache/document-cache.ts +53 -7
  145. package/src/cache/handle-snapshot.ts +52 -9
  146. package/src/cache/invalidation-order.ts +84 -0
  147. package/src/cache/request-tag-mask.ts +147 -0
  148. package/src/cache/shell-snapshot.ts +130 -348
  149. package/src/cache/tag-invalidation.ts +57 -51
  150. package/src/cache/types.ts +71 -67
  151. package/src/cache/vercel/vercel-cache-store.ts +61 -58
  152. package/src/client-urls/client-root.tsx +8 -4
  153. package/src/client-urls/client-urls.ts +8 -6
  154. package/src/client-urls/navigation.ts +55 -4
  155. package/src/client-urls/server-projection.ts +31 -10
  156. package/src/client-urls/types.ts +12 -5
  157. package/src/client.tsx +2 -0
  158. package/src/host/cookie-handler.ts +2 -1
  159. package/src/index.rsc.ts +3 -0
  160. package/src/index.ts +3 -0
  161. package/src/loader-redirect.tsx +7 -3
  162. package/src/prerender/build-shell-capture.ts +155 -146
  163. package/src/route-definition/dsl-helpers.ts +2 -0
  164. package/src/route-definition/helpers-types.ts +11 -11
  165. package/src/route-name.ts +12 -0
  166. package/src/router/content-negotiation.ts +4 -1
  167. package/src/router/error-handling.ts +5 -2
  168. package/src/router/handler-context.ts +10 -7
  169. package/src/router/intercept-resolution.ts +11 -1
  170. package/src/router/loader-resolution.ts +28 -3
  171. package/src/router/logging.ts +5 -3
  172. package/src/router/match-api.ts +62 -34
  173. package/src/router/match-context.ts +5 -0
  174. package/src/router/match-handlers.ts +9 -29
  175. package/src/router/match-middleware/background-revalidation.ts +3 -5
  176. package/src/router/match-middleware/cache-lookup.ts +35 -19
  177. package/src/router/match-middleware/cache-store.ts +15 -9
  178. package/src/router/match-result.ts +1 -0
  179. package/src/router/middleware.ts +2 -5
  180. package/src/router/navigation-snapshot.ts +6 -5
  181. package/src/router/prerender-match.ts +4 -0
  182. package/src/router/request-classification.ts +7 -4
  183. package/src/router/router-options.ts +4 -1
  184. package/src/router/segment-resolution/fresh.ts +0 -4
  185. package/src/router/segment-resolution/loader-cache.ts +150 -25
  186. package/src/router/segment-resolution/loader-mask.ts +3 -4
  187. package/src/router/segment-resolution/loader-snapshot.ts +15 -78
  188. package/src/router/segment-resolution/mask-nested.ts +47 -22
  189. package/src/router/segment-resolution/revalidation.ts +0 -3
  190. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  191. package/src/router/telemetry.ts +6 -3
  192. package/src/router/transition-when.ts +34 -52
  193. package/src/router.ts +2 -1
  194. package/src/rsc/attach-transition-when.ts +47 -0
  195. package/src/rsc/full-payload.ts +30 -10
  196. package/src/rsc/handler.ts +12 -10
  197. package/src/rsc/loader-fetch.ts +2 -1
  198. package/src/rsc/manifest-init.ts +4 -0
  199. package/src/rsc/origin-guard.ts +8 -5
  200. package/src/rsc/progressive-enhancement.ts +7 -30
  201. package/src/rsc/response-cache-serve.ts +68 -80
  202. package/src/rsc/response-route-handler.ts +2 -2
  203. package/src/rsc/rsc-rendering.ts +206 -252
  204. package/src/rsc/server-action.ts +8 -20
  205. package/src/rsc/shell-build-manifest.ts +22 -20
  206. package/src/rsc/shell-capture-constants.ts +136 -20
  207. package/src/rsc/shell-capture.ts +352 -589
  208. package/src/rsc/shell-serve.ts +73 -109
  209. package/src/rsc/ssr-setup.ts +3 -2
  210. package/src/rsc/types.ts +2 -0
  211. package/src/segment-system.tsx +40 -7
  212. package/src/server/context.ts +189 -124
  213. package/src/server/cookie-store.ts +153 -127
  214. package/src/server/handle-store.ts +245 -27
  215. package/src/server/request-context.ts +123 -84
  216. package/src/server/request-headers.ts +134 -0
  217. package/src/server.ts +4 -0
  218. package/src/testing/dispatch.ts +4 -7
  219. package/src/testing/e2e/index.ts +1 -0
  220. package/src/testing/index.ts +5 -0
  221. package/src/testing/internal/action-id.ts +25 -0
  222. package/src/testing/internal/context.ts +1 -1
  223. package/src/testing/location-state-key.ts +45 -0
  224. package/src/testing/render-handler.ts +1 -1
  225. package/src/testing/render-route.tsx +109 -16
  226. package/src/testing/run-client-revalidate.ts +3 -19
  227. package/src/testing/run-loader.ts +5 -1
  228. package/src/testing/run-transition-when.ts +109 -172
  229. package/src/testing/serve-shell-request.ts +10 -7
  230. package/src/testing/shell-status.ts +60 -56
  231. package/src/transition-when-ref.ts +220 -0
  232. package/src/types/cache-types.ts +15 -3
  233. package/src/types/index.ts +3 -0
  234. package/src/types/segments.ts +94 -91
  235. package/src/urls/path-helper-types.ts +3 -4
  236. package/src/urls/pattern-types.ts +3 -4
  237. package/src/vite/discovery/discover-routers.ts +3 -0
  238. package/src/vite/discovery/shell-prerender-phase.ts +0 -2
  239. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  240. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  241. package/src/vite/rango.ts +2 -0
  242. package/src/vite/router-discovery.ts +75 -5
  243. package/dist/types/rsc/transition-gate.d.ts +0 -23
  244. package/src/rsc/transition-gate.ts +0 -86
@@ -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, because a shell HIT never runs a handler.
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,
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type {
3
3
  NavigationLocation,
4
- NavigateOptions,
4
+ NavigateOptionsInternal,
5
5
  TrackedActionState,
6
6
  ActionLifecycleState,
7
7
  InflightAction,
@@ -46,7 +46,7 @@ export interface NavigationEntry {
46
46
  abort: AbortController;
47
47
  phase: NavigationPhase;
48
48
  startedAt: number;
49
- options?: NavigateOptions & { skipLoadingState?: boolean };
49
+ options?: NavigateOptionsInternal & { skipLoadingState?: boolean };
50
50
  }
51
51
 
52
52
  /**
@@ -206,7 +206,7 @@ export interface EventController {
206
206
  // Navigation operations
207
207
  startNavigation(
208
208
  url: string,
209
- options?: NavigateOptions & { skipLoadingState?: boolean },
209
+ options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
210
210
  ): NavigationHandle;
211
211
  abortNavigation(): void;
212
212
 
@@ -632,7 +632,7 @@ export function createEventController(
632
632
 
633
633
  function startNavigation(
634
634
  url: string,
635
- options?: NavigateOptions & { skipLoadingState?: boolean },
635
+ options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
636
636
  ): NavigationHandle {
637
637
  // Cancel existing navigation (switchMap semantics)
638
638
  if (currentNavigation) {
@@ -1,5 +1,8 @@
1
1
  import {
2
+ isLocationStateDefinition,
2
3
  isLocationStateEntry,
4
+ peekLocationStateKey,
5
+ replaceCurrentHistoryState,
3
6
  resolveLocationStateEntries,
4
7
  } from "./react/location-state-shared.js";
5
8
 
@@ -13,10 +16,49 @@ function isTypedLocationState(
13
16
  return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
14
17
  }
15
18
 
19
+ /**
20
+ * Dev-only guard for the state mistakes `HistoryState` rejects at compile
21
+ * time, for untyped callers: a typed entry without its array (its `__rsc_ls_*`
22
+ * fields would be spread onto history.state, so useLocationState reads
23
+ * undefined), an uncalled definition (pushState throws DataCloneError), and an
24
+ * array mixing entries with plain values (only `state[0]` picks the format, so
25
+ * the rest land under history.state["undefined"] or are never read).
26
+ */
27
+ function assertNavigationState(state: unknown): void {
28
+ if (isLocationStateEntry(state)) {
29
+ throw new Error(
30
+ `[rango] navigation state is a single location-state entry (key "${state.__rsc_ls_key}"). ` +
31
+ "Wrap it in an array: { state: [MyState(value)] }. " +
32
+ "Without the array, useLocationState(MyState) reads undefined.",
33
+ );
34
+ }
35
+ const definition = (Array.isArray(state) ? state : [state]).find(
36
+ isLocationStateDefinition,
37
+ );
38
+ if (definition) {
39
+ throw new Error(
40
+ `[rango] navigation state contains a location-state definition (key "${peekLocationStateKey(definition) ?? "unset"}") instead of an entry. ` +
41
+ "Call it with the value: { state: [MyState(value)] }, not [MyState].",
42
+ );
43
+ }
44
+ if (Array.isArray(state) && state.some(isLocationStateEntry)) {
45
+ const index = state.findIndex((item) => !isLocationStateEntry(item));
46
+ if (index !== -1) {
47
+ throw new Error(
48
+ `[rango] navigation state mixes location-state entries with other values (index ${index}). ` +
49
+ "Pass only entries ({ state: [MyState(value), Other(value)] }); plain state cannot sit next to typed entries.",
50
+ );
51
+ }
52
+ }
53
+ }
54
+
16
55
  /**
17
56
  * Resolve navigation state - handles both LocationStateEntry[] and plain formats
18
57
  */
19
58
  export function resolveNavigationState(state: unknown): unknown {
59
+ if (process.env.NODE_ENV !== "production") {
60
+ assertNavigationState(state);
61
+ }
20
62
  if (
21
63
  Array.isArray(state) &&
22
64
  state.length > 0 &&
@@ -61,6 +103,15 @@ export function buildHistoryState(
61
103
  return Object.keys(result).length > 0 ? result : null;
62
104
  }
63
105
 
106
+ /** Check if a history state object contains location state keys. */
107
+ export function hasLocationState(state: unknown): boolean {
108
+ if (!state || typeof state !== "object") return false;
109
+ return (
110
+ "state" in state ||
111
+ Object.keys(state).some((k) => k.startsWith("__rsc_ls_"))
112
+ );
113
+ }
114
+
64
115
  /**
65
116
  * Stamp an `idx` on the next history entry's state and call push/replaceState.
66
117
  * Push increments the current idx; replace keeps it. Initial entry idx is 0.
@@ -117,7 +168,7 @@ export function mergeLocationState(
117
168
  ...window.history.state,
118
169
  ...locationState,
119
170
  };
120
- window.history.replaceState(merged, "", window.location.href);
171
+ replaceCurrentHistoryState(merged);
121
172
  if (Object.keys(locationState).some((k) => k.startsWith("__rsc_ls_"))) {
122
173
  window.dispatchEvent(new Event("__rsc_locationstate"));
123
174
  }
@@ -334,6 +334,7 @@ export function setupLinkInterception(
334
334
  const scrollAttr = link.getAttribute("data-scroll");
335
335
  const replaceAttr = link.getAttribute("data-replace");
336
336
  const revalidateAttr = link.getAttribute("data-revalidate");
337
+ const transitionAttr = link.getAttribute("data-transition");
337
338
 
338
339
  const navigateOptions: NavigateOptions = {};
339
340
  if (scrollAttr === "false") {
@@ -345,6 +346,9 @@ export function setupLinkInterception(
345
346
  if (revalidateAttr === "false") {
346
347
  navigateOptions.revalidate = false;
347
348
  }
349
+ if (transitionAttr === "false") {
350
+ navigateOptions.transition = false;
351
+ }
348
352
 
349
353
  onNavigate(href, navigateOptions);
350
354
  };
@@ -14,7 +14,11 @@ import {
14
14
  createNavigationTransaction,
15
15
  resolveNavigationState,
16
16
  } from "./navigation-transaction.js";
17
- import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
17
+ import {
18
+ buildHistoryState,
19
+ hasLocationState,
20
+ pushHistoryWithIdx,
21
+ } from "./history-state.js";
18
22
  import {
19
23
  handleNavigationStart,
20
24
  handleNavigationEnd,
@@ -30,7 +34,11 @@ import {
30
34
  setupDelegatedLinkPrefetch,
31
35
  setupLinkInterception,
32
36
  } from "./link-interceptor.js";
33
- import { createPartialUpdater } from "./partial-update.js";
37
+ import {
38
+ createPartialUpdater,
39
+ shouldStartViewTransition,
40
+ } from "./partial-update.js";
41
+ import { decideCommitGatedOff, mergeSegmentParams } from "./transition-when.js";
34
42
  import { generateHistoryKey } from "./navigation-store.js";
35
43
  import type { EventController } from "./event-controller.js";
36
44
  import { isInterceptOnlyCache } from "./intercept-utils.js";
@@ -214,6 +222,7 @@ export function createNavigationBridge(
214
222
 
215
223
  // Snapshot old state before pushState/replaceState overwrites it
216
224
  const oldState = window.history.state;
225
+ const shallowRouteName = store.getHistoryEntryMemory()?.routeName;
217
226
 
218
227
  // Update browser URL (carry intercept context into history state)
219
228
  const historyState = buildHistoryState(
@@ -228,18 +237,11 @@ export function createNavigationBridge(
228
237
 
229
238
  // Ensure new history entry has a scroll restoration key
230
239
  ensureHistoryKey();
240
+ // Same route, new entry (transition({ when }) back/forward source).
241
+ store.rememberDisplayedEntry(shallowRouteName);
231
242
 
232
243
  // Notify useLocationState() hooks when state changes
233
- const hasOldState =
234
- oldState &&
235
- typeof oldState === "object" &&
236
- ("state" in oldState ||
237
- Object.keys(oldState).some((k) => k.startsWith("__rsc_ls_")));
238
- const hasNewState =
239
- historyState &&
240
- ("state" in historyState ||
241
- Object.keys(historyState).some((k) => k.startsWith("__rsc_ls_")));
242
- if (hasOldState || hasNewState) {
244
+ if (hasLocationState(oldState) || hasLocationState(historyState)) {
243
245
  window.dispatchEvent(new Event("__rsc_locationstate"));
244
246
  }
245
247
 
@@ -344,9 +346,44 @@ export function createNavigationBridge(
344
346
  state: resolvedState,
345
347
  skipLoadingState: false,
346
348
  });
349
+ // A cross-route clientUrls() navigation presents its destination
350
+ // optimistically at once: transition({ when }) decides THERE (the first
351
+ // presentation), over the destination route's predicate and those of
352
+ // the segments the navigation keeps (everything outside the group's
353
+ // route segment), and the canonical commit reuses the decision.
354
+ // `transition: false` gates the navigation off without calling any
355
+ // predicate, on both the optimistic swap and the canonical commit.
356
+ const transitionOptOut = options?.transition === false;
347
357
  const clientUrlPresentation = beginClientUrlNavigation(
348
358
  targetUrl,
349
359
  tx.handle.signal,
360
+ transitionOptOut
361
+ ? () => true
362
+ : (destination) =>
363
+ decideCommitGatedOff(
364
+ store,
365
+ (
366
+ store.getCachedSegments(store.getHistoryKey())?.segments ?? []
367
+ ).filter((segment) => segment.clientGroup === undefined),
368
+ options?.replace ? "replace" : "push",
369
+ {
370
+ to: (from) => {
371
+ // Mount (include prefix) params carry over; the origin
372
+ // route's own params do not.
373
+ const params = { ...from.params };
374
+ for (const name of destination.originLocalParamNames) {
375
+ delete params[name];
376
+ }
377
+ return {
378
+ url: targetUrl,
379
+ params: { ...params, ...destination.params },
380
+ routeName: destination.routeName,
381
+ state: buildHistoryState(resolvedState),
382
+ };
383
+ },
384
+ extra: destination.when,
385
+ },
386
+ ),
350
387
  );
351
388
 
352
389
  // REVALIDATE: Fetch fresh data from server
@@ -365,6 +402,9 @@ export function createNavigationBridge(
365
402
  replace: options?.replace,
366
403
  scroll: options?.scroll,
367
404
  state: resolvedState,
405
+ transitionGatedOff: transitionOptOut
406
+ ? true
407
+ : clientUrlPresentation?.transitionGatedOff,
368
408
  }),
369
409
  isLeavingIntercept
370
410
  ? { type: "leave-intercept" as const }
@@ -394,6 +434,7 @@ export function createNavigationBridge(
394
434
  return this.navigate(redirectUrl, {
395
435
  state: error.state,
396
436
  replace: options?.replace,
437
+ transition: options?.transition,
397
438
  _skipCache: true,
398
439
  } as NavigateOptionsInternal);
399
440
  }
@@ -457,6 +498,7 @@ export function createNavigationBridge(
457
498
  false,
458
499
  tx.handle.signal,
459
500
  tx.with({ url: window.location.href, replace: true, scroll: false }),
501
+ { type: "navigate", refresh: true },
460
502
  );
461
503
  } catch (error) {
462
504
  // Aborted or superseded: bail without rendering a boundary (see navigate()).
@@ -567,6 +609,26 @@ export function createNavigationBridge(
567
609
  const isStale = (cached?.stale ?? false) || isActionFenceActive();
568
610
 
569
611
  if (cachedSegments && cachedSegments.length > 0) {
612
+ // transition({ when }) decides the restore (kind "pop") against the
613
+ // entry being left, so before the store moves to the restored entry.
614
+ // The decision the entry was committed with is not reused: it was
615
+ // made for a different source. A restore that opens or closes an
616
+ // intercept never holds or animates (hasTransition below), so it is
617
+ // not decided, like every intercept commit.
618
+ const cachedParams = mergeSegmentParams(cachedSegments);
619
+ const gatedOff =
620
+ !isIntercept &&
621
+ !isLeavingIntercept &&
622
+ decideCommitGatedOff(store, cachedSegments, "pop", {
623
+ to: () => ({
624
+ url,
625
+ params: cachedParams,
626
+ routeName: store.getHistoryEntryMemory(historyState?.key)
627
+ ?.routeName,
628
+ state: historyState,
629
+ }),
630
+ });
631
+
570
632
  // Update store to point to this history entry
571
633
  store.setHistoryKey(historyKey);
572
634
  store.setSegmentIds(cachedSegments.map((s) => s.id));
@@ -583,14 +645,11 @@ export function createNavigationBridge(
583
645
  try {
584
646
  const root = await renderSegments(cachedSegments, {
585
647
  forceAwait: true,
648
+ transitionGatedOff: gatedOff,
586
649
  });
587
- // Merge params from cached segments for useParams restoration.
650
+ store.rememberDisplayedEntry();
588
651
  // Set params on event controller before onUpdate so both location
589
652
  // and params are current when the debounced notify() fires.
590
- const cachedParams: Record<string, string> = {};
591
- for (const s of cachedSegments) {
592
- if (s.params) Object.assign(cachedParams, s.params);
593
- }
594
653
  eventController.setParams(cachedParams);
595
654
 
596
655
  const popstateUpdate = {
@@ -613,7 +672,7 @@ export function createNavigationBridge(
613
672
  const hasTransition =
614
673
  !isIntercept &&
615
674
  !isLeavingIntercept &&
616
- cachedSegments.some((s) => s.transition);
675
+ shouldStartViewTransition(cachedSegments, gatedOff);
617
676
  if (hasTransition) {
618
677
  startTransition(() => {
619
678
  if (addTransitionType) {
@@ -122,9 +122,10 @@ export function createNavigationClient(
122
122
 
123
123
  // Check completed in-memory prefetch cache before making a network
124
124
  // request. Try the source-scoped key first (populated when the server
125
- // tagged the prefetch response as source-sensitive, e.g. intercepts,
126
- // or when a Link opted in with `prefetchKey=":source"`), then fall
127
- // back to the wildcard slot shared across source pages.
125
+ // tagged the prefetch response as source-sensitive, e.g. a route an
126
+ // intercept targets, or when a Link opted in with
127
+ // `prefetchKey=":source"`), then fall back to the wildcard slot shared
128
+ // across source pages.
128
129
  // Both keys embed the Rango state, so state rotation (deploy or
129
130
  // server-action invalidation) auto-invalidates both scopes.
130
131
  // Skip cache for stale revalidation (needs fresh data), HMR (needs