@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
@@ -489,7 +489,7 @@ layout(<ShopLayout />, () => [
489
489
  "@modal",
490
490
  "product.detail",
491
491
  ProductQuickView,
492
- { when: ({ from }) => from.pathname === "/products" },
492
+ { when: ({ from }) => from.url.pathname === "/products" },
493
493
  () => [
494
494
  loader(ProductLoader),
495
495
  loading(<QuickViewSkeleton />),
@@ -508,8 +508,10 @@ middleware, layout, loaders, loading state, and loader-level caching and
508
508
  `intercept()`) can choose by navigation source.
509
509
 
510
510
  This is integrated with the rest of the runtime rather than implemented as URL
511
- masking plus local component state. Intercepts get source-scoped prefetch entries
512
- so a modal Flight payload cannot leak into direct navigation. A prerendered target
511
+ masking plus local component state. A route an intercept targets gets
512
+ source-scoped prefetch entries whether or not the intercept applied, so neither a
513
+ modal Flight payload nor a full page prefetched from another page is reused where
514
+ the intercept decides differently. A prerendered target
513
515
  can store a separate intercept variant while its loaders still run live at
514
516
  request time. An action can revalidate the open intercept without remounting its
515
517
  subtree or forcing the preserved background to render. See the
@@ -578,7 +580,8 @@ the bounded cache uses a configurable TTL. `Save-Data`,
578
580
  Correctness is part of the prefetch contract. Keys include Rango state, destination
579
581
  URL, and the mounted segment set used to compute the diff. Server actions rotate
580
582
  state and abort stale speculative work; a generation check prevents a late result
581
- from repopulating an invalidated cache. Intercepts are automatically source-scoped,
583
+ from repopulating an invalidated cache. Routes an intercept targets are automatically
584
+ source-scoped, matched or not,
582
585
  and malformed, cross-origin, foreign-router, redirect, and reload responses are
583
586
  dropped rather than warmed. `useLinkStatus()` exposes `{ pending }` for the owning
584
587
  link.
@@ -754,7 +757,7 @@ from two hosts, route scopes, param sets, or query variants therefore does not
754
757
  collapse into one entry just because both calls received a `ctx` object.
755
758
 
756
759
  The same boundary guards operations whose meaning cannot survive a hit. Direct
757
- `cookies()`/`headers()` reads and request/response mutations such as `ctx.set()`,
760
+ `cookies()`/`headers()`/`ctx.request.headers` reads and request/response mutations such as `ctx.set()`,
758
761
  `ctx.header()`, status, theme, and location-state writes throw inside
759
762
  `"use cache"`. If a cached function pushes typed handles through
760
763
  `ctx.use(Breadcrumbs)` or `ctx.use(Meta)`, Rango captures those pushes on a miss and
@@ -219,6 +219,30 @@ the variant suffixes are still appended.
219
219
  error during the render. The current request still gets the errored render,
220
220
  the next request renders fresh, and on a stale refresh the stale entry keeps
221
221
  serving.
222
+ - A page rendered with a visitor's stored theme other than `defaultTheme`.
223
+ That happens only when `Cache-Control` was set after the render (see
224
+ "Theme" below).
225
+
226
+ ## Theme
227
+
228
+ With `theme` enabled, the page payload carries the theme `useTheme()` starts
229
+ at (`initialTheme`). A page whose response opted in to this cache before
230
+ `next()` starts it at the no-cookie default (`defaultTheme`), whoever rendered
231
+ it (#978), so a visitor with no stored theme never inherits the theme of the
232
+ visitor who warmed the entry. A visitor with a stored theme sees theirs after
233
+ hydration, and the `<html>` class is right before paint either way (the theme
234
+ script reads the cookie). Any other render, a 404 included, starts at the
235
+ visitor's own theme.
236
+
237
+ The render decides from the `Cache-Control` the handler or middleware set
238
+ before `await next()`. A header set after `next()` (or in `onResponse`) arrives
239
+ once the page is rendered, so the cache stores that response only when it was
240
+ rendered with the default theme. Set `Cache-Control` in the handler or before
241
+ `next()` so every visitor's miss can fill the entry.
242
+
243
+ A handler that reads `ctx.theme` on a stored route is not guarded: the stored
244
+ document shows that visitor's theme to everyone. Read the theme with
245
+ `useTheme()` instead (see `/theme`).
222
246
 
223
247
  ## Invalidation
224
248
 
@@ -66,7 +66,9 @@ the one for your case.
66
66
  Rango-specific hook for tracking actions called outside a
67
67
  `<form action={...}>` flow.
68
68
  - [`./state.md`](./state.md) — `useLocationState` (persistent + flash state,
69
- `.read()`/`.write()`/`.delete()`), `invalidateClientCache()`.
69
+ `.read()`/`.read(location)`/`.write()`/`.delete()`, typed `state` on `router.push()` /
70
+ `router.replace()`, serializability errors that name the failing field),
71
+ `invalidateClientCache()`.
70
72
  - [`./outlets.md`](./outlets.md) — `Outlet`, `ParallelOutlet`, `useOutlet`.
71
73
  - [`./urls.md`](./urls.md) — `useParams`, `usePathname`, `useSearchParams`,
72
74
  `useHref`, `useMount`, `useReverse`.
@@ -67,8 +67,10 @@ function NavigationControls() {
67
67
  | `forward()` | `history.forward()`. |
68
68
 
69
69
  `push` / `replace` options: `scroll` (`false` keeps the current scroll
70
- position), `revalidate` (default `true`, see below), and `state` (location
71
- state, see [`./state.md`](./state.md)).
70
+ position), `revalidate` (default `true`, see below), `transition` (`false`
71
+ skips the route's `transition()` for this navigation, see below), and `state`
72
+ (location state: `[MyState(value)]` for typed entries, or plain
73
+ structured-clone-safe data; see [`./state.md`](./state.md)).
72
74
 
73
75
  Target resolution: a path starting with `/` is app-absolute and gets the
74
76
  router `basename` prefixed. A relative path (`"cart"`, `"./cart"`) resolves
@@ -95,6 +97,25 @@ If the pathname changes, `revalidate: false` is silently ignored and a full navi
95
97
 
96
98
  Plain `<a>` tags can opt in via `data-revalidate="false"`.
97
99
 
100
+ #### Skipping the transition
101
+
102
+ Pass `transition: false` to present one navigation without the route's
103
+ `transition()`: the commit is urgent (the `loading()` skeleton streams) and no
104
+ view transition runs, the same result as a `transition({ when })` predicate
105
+ returning `false`. No predicate is called. Back/forward, actions and
106
+ revalidation are unaffected.
107
+
108
+ ```tsx
109
+ router.push("/photos/2", { transition: false });
110
+
111
+ <Link to="/photos/2" transition={false}>
112
+ Next
113
+ </Link>;
114
+ ```
115
+
116
+ Plain `<a>` tags can opt in via `data-transition="false"`. See
117
+ `/view-transitions` → "Conditional transitions".
118
+
98
119
  ### useSegments()
99
120
 
100
121
  Access current URL path and matched route segments:
@@ -12,6 +12,9 @@ import { useLocationState, createLocationState } from "@rangojs/router/client";
12
12
 
13
13
  // Define typed state (all export patterns supported)
14
14
  // Keys are auto-injected by the Vite plugin -- no manual key needed.
15
+ // In a unit test (no plugin), key it with withLocationStateKey(ProductState,
16
+ // "ProductState") from @rangojs/router/testing: outside production, an
17
+ // unkeyed definition throws on first use.
15
18
  export const ProductState = createLocationState<{
16
19
  name: string;
17
20
  price: number;
@@ -81,8 +84,87 @@ definition:
81
84
  const state = useLocationState<{ from?: string }>(); // { from?: string } | undefined
82
85
  ```
83
86
 
87
+ ### State on router.push() / router.replace()
88
+
84
89
  The same `state` option exists on `router.push()` / `router.replace()` (see
85
- [`./navigation.md`](./navigation.md)).
90
+ [`./navigation.md`](./navigation.md)), with the same type as `<Link state>`
91
+ (minus the click-time getter): `HistoryState`, which is
92
+ `readonly LocationStateEntry[]` (typed entries) or `PlainHistoryState` (plain,
93
+ structured-clone-safe data). Both types are exported from
94
+ `@rangojs/router/client`.
95
+
96
+ ```tsx
97
+ "use client";
98
+ import { useLocationState, useRouter } from "@rangojs/router/client";
99
+ import { GridState } from "./state"; // createLocationState<{ count: number }>()
100
+
101
+ function LoadMore() {
102
+ const router = useRouter();
103
+ const grid = useLocationState(GridState);
104
+ const count = grid?.count ?? 20;
105
+ return (
106
+ <button
107
+ onClick={() =>
108
+ router.replace("?page=2", { state: [GridState({ count: count + 20 })] })
109
+ }
110
+ >
111
+ Load more
112
+ </button>
113
+ );
114
+ }
115
+ ```
116
+
117
+ Typed entries always go in an array, with nothing else in it. Three mistakes
118
+ are compile errors:
119
+
120
+ ```tsx
121
+ router.push(url, { state: GridState({ count: 3 }) }); // entry without the array
122
+ router.push(url, { state: [GridState] }); // definition, not called
123
+ router.push(url, { state: [GridState({ count: 3 }), { from: "list" }] }); // mixed
124
+ ```
125
+
126
+ Without the array, the entry's own `__rsc_ls_*` fields would land on
127
+ `history.state` and `useLocationState(GridState)` would read `undefined`; an
128
+ uncalled definition throws `DataCloneError` in `history.pushState`; in a mixed
129
+ array only the first element picks the format, so the rest are lost. For
130
+ untyped (JS) callers, development builds throw an error for each that names
131
+ the definition's key (or the offending index) and the fix.
132
+
133
+ Plain state is checked at the top level only. It rejects functions, symbols,
134
+ `Promise`, `WeakMap`, `WeakSet`, and values typed `unknown` (narrow an
135
+ `unknown` value, or annotate it as `PlainHistoryState`). Array, `Map`, and
136
+ `Set` elements are checked the same way, but plain object members are not.
137
+ These still compile and still throw `DataCloneError` at `history.pushState`:
138
+
139
+ - a function, symbol, or React element inside a plain object
140
+ (`{ onClose: () => {} }`);
141
+ - a top-level `ReactElement` or DOM node (`HTMLElement`), which look like
142
+ plain objects to the type.
143
+
144
+ Store plain data and rebuild the rest on arrival, or use a typed
145
+ `createLocationState<T>()` definition, whose `T` is checked all the way down.
146
+
147
+ ### When a type fails the serializability check
148
+
149
+ `createLocationState<T>()` rejects values that cannot survive
150
+ `history.state`'s structured clone: functions, class constructors, symbols,
151
+ React/RSC content, and fields typed `unknown`. The compile error names the
152
+ failing field and the reason:
153
+
154
+ ```
155
+ Argument of type '{ items: never[]; cursor: string; }' is not assignable to
156
+ parameter of type '... & LocationStateUnsafe<"`unknown` cannot be verified as
157
+ serializable; give it a concrete type", "items[].info.values">'.
158
+ ```
159
+
160
+ The path uses `.` for fields, `[string]` / `[number]` for index signatures
161
+ (`Record<string, T>`), `[]` for array and `Set` elements, `[0]` for tuple
162
+ elements, `<key>` / `<value>` for `Map` keys and values, and `<root>` when the
163
+ value itself is unsafe. A field typed `any` is never reported. When several fields fail, the error lists one `LocationStateUnsafe` per
164
+ path. An object's own unsafe fields are reported before anything nested
165
+ deeper, so a class instance or DOM node stops at its methods
166
+ (`row.el.click`); fix those and the next compile names the rest. The walk
167
+ stops 8 levels deep, reporting the deepest path it reached.
86
168
 
87
169
  ### Flash State (read-once)
88
170
 
@@ -160,6 +242,15 @@ const flash = FlashMessage.read();
160
242
  const product = ProductState.read();
161
243
  ```
162
244
 
245
+ `.read(location)` reads this definition's slot from any `{ state }` snapshot
246
+ instead of the current entry, such as the `from` / `to` of a
247
+ `transition({ when })` context. It works without `window` and never clears
248
+ flash state:
249
+
250
+ ```tsx
251
+ transition({ when: ({ to }) => ProductState.read(to) !== undefined });
252
+ ```
253
+
163
254
  > **Hydration:** `.read()` returns `undefined` on the server but may return
164
255
  > a real value on the first client render (history state survives reload).
165
256
  > Do not call `.read()` directly during the initial render of a component;
@@ -191,11 +282,12 @@ const current = ProductState.read();
191
282
  ProductState.delete();
192
283
  ```
193
284
 
194
- | Method | Updates `history.state` | Fires `useLocationState` rerender | SSR behavior |
195
- | ----------- | ----------------------- | --------------------------------- | ------------------- |
196
- | `.read()` | no | n/a (returns snapshot) | returns `undefined` |
197
- | `.write()` | yes (replace this slot) | no | throws |
198
- | `.delete()` | yes (remove this slot) | no | throws |
285
+ | Method | Updates `history.state` | Fires `useLocationState` rerender | SSR behavior |
286
+ | ----------------- | ----------------------- | --------------------------------- | ------------------- |
287
+ | `.read()` | no | n/a (returns snapshot) | returns `undefined` |
288
+ | `.read(location)` | no | n/a (reads the given snapshot) | reads the snapshot |
289
+ | `.write()` | yes (replace this slot) | no | throws |
290
+ | `.delete()` | yes (remove this slot) | no | throws |
199
291
 
200
292
  ## Cache Control
201
293
 
@@ -214,24 +214,41 @@ intercept(
214
214
  "product",
215
215
  <ProductModal />,
216
216
  // Only intercept when coming from a different section
217
- { when: ({ from }) => !from.pathname.startsWith("/shop/product/") },
217
+ { when: ({ from }) => !from.url.pathname.startsWith("/shop/product/") },
218
218
  () => [
219
219
  loader(ProductLoader),
220
220
  ]
221
221
  )
222
222
  ```
223
223
 
224
- `when` is a synchronous match-time selector. It receives `from` / `to` (URLs),
225
- `fromRouteName` / `toRouteName` (named routes only), `params` (the target's),
226
- `segments` (the client's current segment path and ids), `request`, and `env`.
227
- Pass an array of predicates for AND logic (all must return true). Omit `when`
228
- entirely and the intercept always activates. `when` is not re-evaluated during
229
- action revalidation, so an open modal stays open after an action.
224
+ `when` is a synchronous match-time selector that runs on the server. It
225
+ receives `from` and `to` (the destination), each `{ url, params, routeName }`
226
+ — the shape `transition({ when })` sees, without `state`, because history
227
+ state never reaches the server — plus `segments` (the client's current
228
+ segment path and ids), `request`, and `env`. `from` is the page the
229
+ navigation leaves, except while an intercept is open: then it is the
230
+ intercept's source page, the page under the modal (`match-api.ts`,
231
+ `navigation-snapshot.ts` `effectiveFromUrl`). Opening `/item/1` as a modal
232
+ over `/list/open` and then navigating to `/item/2` from inside the modal
233
+ gives `from.url.pathname === "/list/open"`, not `/item/1`. That differs from
234
+ `transition({ when })`'s `from`, which is always the committed location on
235
+ screen. `routeName` is set for named routes only. Pass an array of predicates
236
+ for AND logic (all must return true). Omit `when` entirely and the intercept
237
+ always activates. `when` is not re-evaluated during action revalidation, so
238
+ an open modal stays open after an action. A selector that throws does not
239
+ intercept: the full page renders and the error is logged with the route name.
240
+
241
+ A prefetch of a route an intercept targets is stored per source page, whether
242
+ or not the intercept applies from that page (`rsc-rendering.ts`
243
+ `x-rsc-prefetch-scope: source`, set from `MatchResult.interceptTargeted`). A
244
+ full page prefetched from a page where `when` is false never serves a click
245
+ from a page where it is true. Routes no intercept targets keep one shared
246
+ prefetch entry.
230
247
 
231
248
  ```typescript
232
249
  // Intercept only when opened from the shop index
233
250
  intercept("@modal", "product", <ProductModal />, {
234
- when: ({ fromRouteName }) => fromRouteName === "index",
251
+ when: ({ from }) => from.routeName === "index",
235
252
  })
236
253
  ```
237
254
 
@@ -242,8 +259,8 @@ intercept(
242
259
  <ProductModal />,
243
260
  {
244
261
  when: [
245
- ({ from }) => from.pathname.startsWith("/shop"),
246
- ({ params }) => params.slug !== "featured",
262
+ ({ from }) => from.url.pathname.startsWith("/shop"),
263
+ ({ to }) => to.params.slug !== "featured",
247
264
  ],
248
265
  },
249
266
  () => [
@@ -401,7 +418,7 @@ layout(ShopLayout, () => [
401
418
  "@modal",
402
419
  ".detail",
403
420
  <ProductModal />,
404
- { when: ({ from }) => from.pathname.startsWith("/shop") },
421
+ { when: ({ from }) => from.url.pathname.startsWith("/shop") },
405
422
  () => [loader(ProductLoader)],
406
423
  ),
407
424
  ])
@@ -482,7 +499,7 @@ export const shopPatterns = urls(({
482
499
  "@modal",
483
500
  ".product", // dot-local: resolves to "shop.product" in this include
484
501
  <ProductModalContent />,
485
- { when: ({ from }) => !from.pathname.startsWith("/shop/product/") },
502
+ { when: ({ from }) => !from.url.pathname.startsWith("/shop/product/") },
486
503
  () => [
487
504
  layout(<ModalWrapper />),
488
505
  loading(<ProductModalSkeleton />),
@@ -139,11 +139,13 @@ A DSL loader without `ssr: false` can read `createVar({ cache: false })`
139
139
  variables: it runs on every request, `cache()` and PPR shell HITs included, so
140
140
  the read guard is bypassed for it. On a `ppr` route the capture runs an
141
141
  `ssr: false` loader, and a loader a handler awaits, and bakes their values into
142
- the shell: a `cookies()`, `headers()` or `{ cache: false }` read there refuses
143
- the capture, so the route keeps rendering without a shell (warned once per key).
142
+ the shell: a `cookies()`, `headers()`, `ctx.request.headers` or
143
+ `{ cache: false }` read there refuses the capture, so the route keeps
144
+ rendering without a shell (warned once per key).
144
145
  A loader bound with its own `cache()` is the other exception: its value is
145
- stored and shared, so a miss whose body read `cookies()`, `headers()`, a
146
- non-cacheable variable or the theme fails unless the binding has a `key()` or a
146
+ stored and shared, so a miss whose body read `cookies()`, `headers()`,
147
+ `ctx.request.headers`, a non-cacheable variable or the theme fails unless the
148
+ binding has a `key()` or a
147
149
  store `keyGenerator`, which must then include them (see "Cache Key"). A
148
150
  `"use cache"` function the loader calls is not a loader body: a non-cacheable
149
151
  read inside it throws, also through the loader's `ctx` passed in, so read the
@@ -191,8 +193,9 @@ the tree).
191
193
  capture-time copy served on every HIT (a HIT never runs the handler);
192
194
  `useLoader` of a loader without `ssr: false` is the live lane (an `ssr: false`
193
195
  loader is the bake lane however it is read). Unlike `cache()`, an identity
194
- read inside that loader (`cookies()`, `headers()`, a `{ cache: false }`
195
- variable) refuses the capture, so the route stays uncached. One rule across
196
+ read inside that loader (`cookies()`, `headers()`, `ctx.request.headers`, a
197
+ `{ cache: false }` variable) refuses the capture, so the route stays
198
+ uncached. One rule across
196
199
  `cache()`, `"use cache"`, and PPR: the consumption-lane rule (`/rango` →
197
200
  Invariants).
198
201
  - Non-cacheable variable reads (`createVar({ cache: false })`) inside the
@@ -256,7 +259,8 @@ export const ProductLoader = createLoader(async (ctx) => {
256
259
  // Platform bindings (DB, KV, etc.) — plain bindings from createRouter<TEnv>()
257
260
  const db = ctx.env.DB;
258
261
 
259
- // Request headers
262
+ // Request headers (guarded like headers(): an unkeyed loader cache() fill
263
+ // or a ppr capture of this loader refuses the read)
260
264
  const auth = ctx.request.headers.get("Authorization");
261
265
 
262
266
  // Variables set by middleware (from Rango.Vars augmentation)
@@ -654,6 +658,7 @@ at two levels:
654
658
  import { cookies } from "@rangojs/router";
655
659
 
656
660
  // Full override — key function replaces the default entirely
661
+ // Stored as "loader:{loaderId}:key:product%3Ashoe%3Aen"
657
662
  loader(ProductLoader, () => [
658
663
  cache({
659
664
  ttl: 300,
@@ -665,12 +670,22 @@ loader(ProductLoader, () => [
665
670
  // Set in the store configuration, applies to all entries in that store
666
671
  ```
667
672
 
668
- Resolution priority (same as route-level `cache()`):
673
+ Resolution priority (same order as route-level `cache()`):
669
674
 
670
- 1. `key(ctx)` from cache options — full override
671
- 2. `store.keyGenerator(ctx, defaultKey)` — store-level modification
675
+ 1. `key(ctx)` from cache options — full override, stored namespaced as
676
+ `loader:{loaderId}:key:` plus the result's URI encoding
677
+ 2. `store.keyGenerator(ctx, defaultKey)` — store-level modification, stored
678
+ as returned
672
679
  3. Default key — `loader:{loaderId}:{host}{pathname}:{sortedParams}`
673
680
 
681
+ Because the `key()` result is namespaced by the loader id, no value it
682
+ returns, request input included, can name another loader's entry or a
683
+ default key: two loaders whose `key()` returns the same value keep separate
684
+ entries. A readable prefix is still good practice. A store `keyGenerator`
685
+ result is stored as returned, so build it on the `defaultKey` it receives
686
+ (which carries the loader id): a generator that returns raw request input
687
+ can still name another entry.
688
+
674
689
  A `key` function (or store `keyGenerator`) that throws is **not** caught: the
675
690
  loader fails as if its body threw. There is no silent fallback to the default
676
691
  key, because a personalised key collapsing onto the broad default would share
@@ -680,9 +695,10 @@ one user's data with everyone.
680
695
  names no user, so one entry serves everyone who requests the same loader, host,
681
696
  path and params. A route `cache()` around the route does not partition it
682
697
  either: a loader's own `cache()` is an independent layer, keyed only by what it
683
- declares. On a miss, a body that reads `cookies()`, `headers()` or a
684
- non-cacheable variable (`createVar({ cache: false })`, or a value written with
685
- `ctx.set(..., { cache: false })`) therefore **fails**, and nothing is stored,
698
+ declares. On a miss, a body that reads `cookies()`, `headers()`,
699
+ `ctx.request.headers` or a non-cacheable variable (`createVar({ cache: false })`,
700
+ or a value written with `ctx.set(..., { cache: false })`) therefore **fails**,
701
+ and nothing is stored,
686
702
  unless the binding declares identity with a `key()` or a store `keyGenerator`.
687
703
  The check tests only that one of them is there, not what it contains: any
688
704
  `key()` or store `keyGenerator` switches it off, so it must itself include the
@@ -720,11 +736,25 @@ stored, and `onError` gets the same error. A push still pending after the
720
736
  timeout drops the entry's handles, and the value is stored without them. A loader with no `cache()` of its own
721
737
  reads request data freely.
722
738
 
723
- Two kinds of read are not seen, the same as in `"use cache"`: raw reads such
724
- as `ctx.request.headers` or `getRequestContext().cookie()`, and a value
725
- computed outside the loader and handed in, such as a per-request memo a handler
726
- filled from `cookies()` before the loader awaited it. Key what those carry
727
- too. If the value must stay per request, drop the loader's `cache()` instead.
739
+ The raw reads count the same: `ctx.request.headers` (the loader's
740
+ `ctx.request` is the request's own `Request`) and `getRequestContext().cookie()`
741
+ / `.cookies()` fail an unkeyed miss exactly like `cookies()`. A `key()` or a
742
+ store `keyGenerator` reads them freely, since that is how the entry is
743
+ partitioned:
744
+
745
+ ```typescript
746
+ loader(LocaleLoader, () => [
747
+ cache({
748
+ ttl: 60,
749
+ key: (ctx) => `lang:${ctx.request.headers.get("accept-language") ?? "en"}`,
750
+ }),
751
+ ]),
752
+ ```
753
+
754
+ One kind of read is not seen, the same as in `"use cache"`: a value computed
755
+ outside the loader and handed in, such as a per-request memo a handler filled
756
+ from `cookies()` before the loader awaited it. Key what it carries too. If the
757
+ value must stay per request, drop the loader's `cache()` instead.
728
758
 
729
759
  ### Tags for Invalidation
730
760
 
@@ -797,7 +827,8 @@ loader(ProductLoader, () => [
797
827
  ```
798
828
 
799
829
  When `condition` returns false, the loader runs fresh and the cache is bypassed
800
- entirely (no read, no write).
830
+ entirely (no read, no write). Like `key()`, a `condition()` reads request
831
+ identity freely: it decides whether the entry is used, it is not stored in it.
801
832
 
802
833
  ### Per-Loader Store Override
803
834
 
@@ -1035,8 +1066,8 @@ The costs and constraints:
1035
1066
  loader lane rule. On a HIT, a flagged loader whose return has no promises is
1036
1067
  served from the shell and does not run (unless the capture marked it to run:
1037
1068
  see `/ppr` → On a shell HIT); one whose return carries promises
1038
- runs on every HIT, with its baked parts overlaid. A `cookies()`, `headers()`
1039
- or `{ cache: false }` read in it refuses the capture. The
1069
+ runs on every HIT, with its baked parts overlaid. A `cookies()`, `headers()`,
1070
+ `ctx.request.headers` or `{ cache: false }` read in it refuses the capture. The
1040
1071
  `progressiveChunkSize` auto-raise is live-document only; captured shells
1041
1072
  outline per the explicit option or React's default.
1042
1073
 
@@ -274,6 +274,24 @@ export const myMiddleware: Middleware = async (ctx, next) => {
274
274
  Cookies and request headers are not `ctx` members; use the free functions
275
275
  `cookies()` and `headers()` from `@rangojs/router`.
276
276
 
277
+ Middleware reads request identity freely: `cookies()`, `headers()` and
278
+ `ctx.request.headers` alike. The same `Request` reaches handlers and loaders
279
+ as `ctx.request`, and there `ctx.request.headers` answers like `headers()`: it
280
+ throws inside a `cache()` boundary or a `"use cache"` function and refuses a
281
+ `ppr` shell capture (#976). To render a header on a cached or `ppr` route,
282
+ copy it here with `ctx.set()` (a cacheable variable) and include it in the
283
+ route's `cache()` `key()`, so each value gets its own record and shell:
284
+
285
+ ```typescript
286
+ const Tier = createVar<string>();
287
+
288
+ export const tierMiddleware: Middleware = async (ctx, next) => {
289
+ ctx.set(Tier, ctx.request.headers.get("x-tier") ?? "free");
290
+ return next();
291
+ };
292
+ // cache({ key: (ctx) => `tier:${ctx.request.headers.get("x-tier") ?? "free"}` }, ...)
293
+ ```
294
+
277
295
  ### Changing the response after `next()`
278
296
 
279
297
  After `await next()`, `ctx.header()` and `ctx.headers` write to the real
@@ -303,7 +303,7 @@ The main content always goes through `<Outlet />` via the `path()` handler.
303
303
  layout(<ShopLayout />, () => [
304
304
  path("/product/:id", ProductPage, { name: "product" }),
305
305
  intercept("@modal", ".product", <ProductModal />, {
306
- when: ({ from }) => from.pathname.startsWith("/shop"),
306
+ when: ({ from }) => from.url.pathname.startsWith("/shop"),
307
307
  }),
308
308
  ])
309
309
  ```
@@ -504,8 +504,8 @@ Differences that matter during migration:
504
504
  gives one shell per key value, and `cache(false)` or a false `condition()`
505
505
  means no shell.
506
506
  - **Request-scoped reads in shell material refuse the capture** (in Next they
507
- silently force dynamic rendering). `cookies()`, `headers()`, a
508
- `{ cache: false }` variable, and `ctx.dynamic()` refuse it anywhere the
507
+ silently force dynamic rendering). `cookies()`, `headers()`,
508
+ `ctx.request.headers`, a `{ cache: false }` variable, and `ctx.dynamic()` refuse it anywhere the
509
509
  capture waits: a handler, a promise it passes or pushes, an async component,
510
510
  a loader it awaits. Per-user reads must move into a live loader (no
511
511
  `ssr: false`; a nested promise does not help). A normal `ctx.get()` value is
@@ -97,7 +97,7 @@ and, when the tail finishes, one console line (wrapped here):
97
97
  ```
98
98
  [RSC Perf] GET /ppr-large/holes shell tail: complete snapshot=4ms
99
99
  snapshot-read=2ms snapshot-bytes=1019145b snapshot-parse-cpu=2ms
100
- records=segment:1 pruned=item:5 seed=4ms seed-cpu=0ms match=4ms handover=4ms
100
+ records=segment:1 seed=4ms seed-cpu=0ms match=4ms handover=4ms
101
101
  first-html=5ms complete=55ms prelude=628949b tail=1033762b
102
102
  ```
103
103
 
@@ -116,10 +116,11 @@ first-html=5ms complete=55ms prelude=628949b tail=1033762b
116
116
  hinted marker reads ran before the entry named its tags), and `fresh-reads`
117
117
  when the request carried the cookie.
118
118
  - `records=` counts the snapshot records the tail was seeded with, by
119
- family; `pruned=` counts the ones the capture dropped because no reader of
120
- the entry consumes them. Here every HIT tail replays the handler layer from
121
- the one segment record, so the five `"use cache"` item records that produced
122
- it were not stored.
119
+ family. Here every HIT tail replays the handler layer from the one segment
120
+ record; a bake-lane loader adds `loader:N` pins. `pruned=` appears only when
121
+ a navigation-only entry drops the loader pins it has no reader for
122
+ (`pruned=loader:N`): a document entry records only the doc record and the
123
+ loader pins, so there is nothing else to prune.
123
124
  - `snapshot=`, `seed=`, `match=`, `first-html=` are offsets from the commit,
124
125
  not durations; `snapshot-read=`, `snapshot-parse-cpu=` and `seed-cpu=` are
125
126
  durations.
@@ -280,7 +280,7 @@ layout(<AccountLayout />, () => [
280
280
 
281
281
  A slot's `loading()` (whether from `handler.use` or explicit) makes that slot an independent streaming unit, exactly as in the **Streaming Behavior** section above.
282
282
 
283
- Under a shared artifact (`cache()`, `"use cache"`, a PPR shell), the server-side `await ctx.use(CartLoader)` above is the BAKED lane — the capture-time value freezes into the artifact. Under `cache()`/`"use cache"` an identity read (`cookies()`, `headers()`) inside the loader bakes too; under a PPR shell it refuses the capture, so the route stays uncached. Consume the loader client-side (`useLoader` in a `"use client"` component, the slot's `loading()` as its boundary) to keep the slot live per request. One rule, stated once: `/rango` → Invariants ("the consumption-lane rule").
283
+ Under a shared artifact (`cache()`, `"use cache"`, a PPR shell), the server-side `await ctx.use(CartLoader)` above is the BAKED lane — the capture-time value freezes into the artifact. An identity read (`cookies()`, `headers()`, `ctx.request.headers`) inside the loader bakes too under `cache()`; inside `"use cache"` it throws when the cached function is the first to read the loader in the request (an already-started loader's value is stored unchecked); under a PPR shell it refuses the capture, so the route stays uncached. Consume the loader client-side (`useLoader` in a `"use client"` component, the slot's `loading()` as its boundary) to keep the slot live per request. One rule, stated once: `/rango` → Invariants ("the consumption-lane rule").
284
284
 
285
285
  The `parallel` mount site has the narrowest allow-list for `handler.use` items — slots cannot bring their own middleware or layout, only `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, and `transition`. See [skills/handler-use](../handler-use/SKILL.md) for the full table and merge rules.
286
286