@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
@@ -121,15 +121,15 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
121
121
 
122
122
  **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — the shell is cached separately from the segments.
123
123
 
124
- | Helper | Import | Role |
125
- | ----------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
- | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
127
- | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
128
- | `shellCacheKey(url, searchParams?, partition?)` | same | Production shell store key (`host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` or store `keyGenerator` partitions its record, pass what that function returns for the request as `partition`: the shell is partitioned the same way. Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. |
129
- | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
130
- | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
131
- | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
132
- | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
124
+ | Helper | Import | Role |
125
+ | ----------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
126
+ | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
127
+ | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
128
+ | `shellCacheKey(url, searchParams?, partition?)` | same | Production shell store key (`host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` partitions its record, pass that function's result for the request as `partition`: the shell is partitioned the same way, the result namespaced as production stores it (`key:` plus its URI encoding). Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. For a store `keyGenerator` that partitions it, pass `{ keys, generated }` (`ShellCachePartition`): `generated` lists the `keyGenerator` results one per store, outermost first: leave them out when every one returns the default key, else keep each position and pass `""` for one that returns it. |
129
+ | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
130
+ | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
131
+ | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
132
+ | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
133
133
 
134
134
  ### What unit can prove vs e2e
135
135
 
@@ -15,7 +15,7 @@
15
15
  | `outletPending` | `boolean` | Seed `useOutlet().pending` through each synthetic segment's production `OutletProvider`. Defaults to `false`; this is context seeding, not a simulated navigation/Suspense/action lifecycle. |
16
16
  | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
17
17
  | `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
18
- | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; written to `history.state`. |
18
+ | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; keys an unkeyed `def` (like `withLocationStateKey(def)`) and writes to `history.state`. See [Location state](#location-state). |
19
19
  | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
20
20
  | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
21
21
  | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
@@ -41,7 +41,7 @@
41
41
  | `useSearchParams` | Search params from the `request` URL. |
42
42
  | `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
43
43
  | `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below). |
44
- | `useLocationState` | SEEDED `history.state` value. |
44
+ | `useLocationState` | SEEDED `history.state` value, or the `state` a push/replace/`<Link>` wrote (below). |
45
45
  | `useHandle` | SEEDED handle output (globally accumulated). |
46
46
  | `Outlet` | Renders the next segment in the chain (layout nesting). |
47
47
  | `useOutlet` | Next-segment `content` plus SEEDED `options.outletPending`. |
@@ -54,13 +54,19 @@ Extends RTL's `RenderResult` (`getByTestId`, `getByText`, `getByRole`, `containe
54
54
  ```ts
55
55
  type RenderRouteResult = RenderResult & {
56
56
  router: {
57
- // client-only nav, re-resolves the same routes; `loaders` seeds THIS nav
57
+ // client-only nav, re-resolves the same routes; `loaders` seeds THIS nav.
58
+ // A spec's transition({ when }) decides it as kind "push";
59
+ // `transition: false` commits urgently and calls no predicate.
58
60
  navigate(
59
61
  url: string,
60
62
  options?: {
61
63
  loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
64
+ transition?: boolean;
62
65
  },
63
66
  ): Promise<void>;
67
+ // re-render the current location, as router.refresh() does:
68
+ // a spec's when gets kind "revalidate" (`to` is `from`)
69
+ refresh(): Promise<void>;
64
70
  pathname(): string;
65
71
  params(): Record<string, string>;
66
72
  store: NavigationStore; // advanced
@@ -114,6 +120,41 @@ it("resolves params + reverse + Outlet through the layout chain", async () => {
114
120
  });
115
121
  ```
116
122
 
123
+ ## Location state
124
+
125
+ A `createLocationState()` definition gets its key from the rango Vite plugin, which a bare Vitest project does not run. Outside production an unkeyed definition throws on first use (`Def(value)`, `useLocationState(Def)`, `.read()`), with a message that points here. Key it with `withLocationStateKey` from `@rangojs/router/testing`:
126
+
127
+ - `withLocationStateKey(GridState, "GridState")` sets `__rsc_ls_GridState` (stable across runs).
128
+ - `withLocationStateKey(GridState)` keeps a key that is already set, else assigns a synthetic `__rsc_ls_test_<n>` that stays the same for that definition.
129
+ - The `locationState` seed option keys an unkeyed definition the same way, so a seeded definition needs no call. Key every definition a component reads WITHOUT a seed.
130
+
131
+ A `useRouter().push/replace(url, { state })` or `<Link state>` navigation writes its history entry through production's path (`resolveNavigationState` -> `buildHistoryState` -> `pushHistoryWithIdx`, then the `__rsc_locationstate` event), so `useLocationState(Def)` re-reads after the click. The dev check for a bare entry or an uncalled definition runs too, and, as in production, a push or `<Link>` without `state` starts an entry with no location state. The URL in `window.location` does not change (renderRoute tracks location on its event controller), and `router.navigate()` from the test leaves `history.state` alone. The click starts an async navigation, so wait for the result with RTL's `waitFor`:
132
+
133
+ ```tsx
134
+ // @vitest-environment happy-dom
135
+ import { afterEach, expect, it } from "vitest";
136
+ import { cleanup, fireEvent, waitFor } from "@testing-library/react";
137
+ import { withLocationStateKey } from "@rangojs/router/testing";
138
+ import { renderRoute } from "@rangojs/router/testing/dom";
139
+ import { LoadMore } from "../src/components/LoadMore"; // router.replace(url, { state: [GridState(...)] })
140
+ import { GridState } from "../src/location-states";
141
+
142
+ afterEach(cleanup);
143
+ withLocationStateKey(GridState, "GridState");
144
+
145
+ it("shows the count it pushed", async () => {
146
+ const { getByTestId } = await renderRoute(
147
+ [{ path: "/grid", Component: LoadMore }],
148
+ { request: "/grid" },
149
+ );
150
+ fireEvent.click(getByTestId("load-more"));
151
+ await waitFor(() => expect(getByTestId("count").textContent).toBe("40"));
152
+ expect(window.history.state).toMatchObject({
153
+ __rsc_ls_GridState: { count: 40 },
154
+ });
155
+ });
156
+ ```
157
+
117
158
  ## Held navigation
118
159
 
119
160
  A stale indicator driven by `useLoader().isLoading` (see `/hooks` data.md, "A held navigation flags the data it keeps on screen") IS unit-testable. Put `transition` on the spec that declares `transition()` in your `urls()`, and pass the next navigation's loader data to `router.navigate(url, { loaders })` as a PENDING Promise. navigate() then commits through production's `commitInTransition` (browser/partial-update.ts), which calls `loaderStore.announcePendingStreams` inside the `startTransition`. The harness does not fake the flag: React holds the reader on screen and the real `useLoader` pin reports `isLoading: true` until the promise settles.
@@ -154,6 +195,7 @@ it("dims the held price while the next product streams", async () => {
154
195
  | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
155
196
  | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
156
197
  | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
198
+ | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
157
199
  | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
158
200
  | `useNavigation().state`, `useLinkStatus().pending`, `useAction().state` | Stay `idle` — navigate() never starts the navigation lifecycle. e2e. |
159
201
 
@@ -121,11 +121,12 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
121
121
  - A loader has no `isAction()`: whether it re-runs after an action is decided by `revalidate()` on its registration, not inside the loader body. Assert post-action re-runs at e2e.
122
122
  - Seeded `loaders` (by-reference tuples) are NOT executed — `ctx.use(OtherLoader)` returns the seeded value. The dynamic `use` resolver, by contrast, IS executed (it is a function called to compute the value). Either way the REAL loader body is not run; real loader execution and side-effects are e2e-only. `loaders` is checked before the `use` resolver.
123
123
  - A `createLoader()` definition imported through the CLIENT build has its body dropped — `runLoader` throws a clear error pointing to the `rangoTestConfig()` preset or the raw body. A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`); only the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules).
124
+ - Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. In the node project (`NODE_ENV` is not `production`) an unkeyed `Flash(value)` throws, so a loader's `throw redirect(url, { state: [Flash(value)] })` surfaces on `runLoaderResult`'s `thrown` as that missing-key error, not the redirect; in the react-server project (`NODE_ENV=production`) the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix).
124
125
  - No `cookies`/`headers` option: seed a cookie by passing a full Request with a Cookie header — `{ request: new Request(url, { headers: { Cookie: "sid=abc" } }) }`. (`search`/`method` are baked onto this request for you.)
125
126
  - `ctx.search` (typed) defaults to `{}`; `opts.search` only sets the raw `ctx.searchParams`. Seed the typed object with `searchData`. (The harness seeds `searchData` verbatim — it does NOT run a typed-search SCHEMA, so schema parsing/validation is e2e.)
126
- - `ctx.theme`/`ctx.setTheme` are NOT on the loader context — they are handler/middleware-only. The `theme` option seeds the request context's theme config, so a loader body reads the visitor's theme with `getRequestContext().theme` (seed the cookie through `request`). That read is guarded like `cookies()` (#971): allowed in a loader body, but it throws inside a `"use cache"` function. `redirect()` does no basename prefixing unless you seed `basename`.
127
+ - `ctx.theme`/`ctx.setTheme` are NOT on the loader context — they are handler/middleware-only. The `theme` option seeds the request context's theme config, so a loader body reads the visitor's theme with `getRequestContext().theme` (seed the cookie through `request`). That read is guarded like `cookies()` (#971): allowed in a loader body, but it throws inside a `"use cache"` function. `ctx.request.headers` is guarded the same way. `redirect()` does no basename prefixing unless you seed `basename`.
127
128
  - A `"use cache"` hit needs real Flight, which loads only under the react-server condition. In the react-server project (a `*.rsc-test.ts` file, see [`./setup.md`](./setup.md)) with `cacheStore` seeded, the body runs once across `runLoader` calls and the value round-trips. The write is a background (`waitUntil`) task `runLoader` does not await: a call before it lands joins the still-running execution, a call after it reads the store (wait with `vi.waitFor` on a `cacheStore.setItem` spy). In the node project a call looks up the store but never writes, so every call runs the body. A function written with the directive needs `rangoUseCacheTransform()` in the project's `plugins` (see [`./render-handler.md`](./render-handler.md#a-use-cache-hit)).
128
- - The `cache` option runs the loader through the production loader-cache read-through, so it needs the react-server project for the same reason. A HIT returns the stored value without running the body and records the tags the body recorded when the entry was written, so `updateTag()` of one (inside `runInRequestContext(fn, { cacheStore })`) drops the entry. There is no in-flight join here: wait for the write before the next call, or that call misses too. A raw body is keyed by its function reference. `runLoaderResult(...).handlePushes` lists the body's pushes, so a HIT reports none (the replay into the page's handle store is not modeled).
129
+ - The `cache` option runs the loader through the production loader-cache read-through, so it needs the react-server project for the same reason. A HIT returns the stored value without running the body and records the tags the body recorded when the entry was written, so `updateTag()` of one (inside `runInRequestContext(fn, { cacheStore })`) drops the entry. There is no in-flight join here: wait for the write before the next call, or that call misses too. A raw body is keyed by its function reference. With no `key()`, a fill whose body read request identity (`cookies()`, `headers()`, `ctx.request.headers`, a `{ cache: false }` variable) fails with the identity error and stores nothing; add a `key()` that includes the value. `runLoaderResult(...).handlePushes` lists the body's pushes, so a HIT reports none (the replay into the page's handle store is not modeled).
129
130
 
130
131
  ```ts
131
132
  const load = () =>
@@ -95,6 +95,7 @@ Pass an array to run several in order. Cookies set inside middleware via the sta
95
95
  - A COMPONENT route's guard stack cannot be exercised through `dispatch` (it throws on component routes), and `renderToFlightString`/`renderRoute` don't run route middleware. Extract the middleware fn and unit-test it here, or assert the guard stack at e2e.
96
96
  - Middleware-phase `ctx.reverse` is map-only (global names only, no auto-fill from current params), matching production — enable it with `routeMap`. `routeName` only feeds `ctx.routeName`; it does NOT scope `.name` reverse (the chain reverse stays map-only by design, and `MiddlewareContext["reverse"]` rejects `.name` at compile time).
97
97
  - `ctx.theme` is `undefined` unless `theme` is passed; `redirect()` does no basename prefixing unless `basename` is seeded.
98
+ - Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run, and outside production an unkeyed `Flash(value)` throws inside the middleware (before `redirect()` or `ctx.setLocationState()` runs). Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix).
98
99
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
99
100
 
100
101
  ## See also
@@ -2,66 +2,66 @@
2
2
 
3
3
  **Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `transition({ when })` (see `/view-transitions`) and `clientUrls()` `revalidate()` (see `/client-urls`)
4
4
 
5
- Both primitives are synchronous and run the router's own evaluation code on arguments you seed, so a predicate sees the same fields it sees at runtime. Neither renders anything: they answer "would this gate keep the transition?" and "would this `clientUrls()` loader re-run?".
6
-
7
- ## runTransitionWhen(config, opts?)
8
-
9
- Runs a `transition()` config through the production gate (`gateTransitions`, plus the PPR pre-handler evaluator when `ppr: true`) and reports whether the transition survives this request.
10
-
11
- ### Options — `RunTransitionWhenOptions<TEnv>`
12
-
13
- Every field is optional. Omitted navigation fields model "source unavailable" (an initial document load); omitted `action*` fields model a plain navigation.
14
-
15
- | Field | Type | Meaning |
16
- | --------------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
17
- | `request` | `Request \| string` | Navigation TARGET (drives `nextUrl`). Defaults to `http://localhost/`. |
18
- | `params` | `Record<string, string>` | Target params (`nextParams`). |
19
- | `toRouteName` | `string` | Target route name (`toRouteName`). |
20
- | `currentUrl` | `string \| URL` | Navigation SOURCE (`currentUrl`). |
21
- | `currentParams` | `Record<string, string>` | Source params (`currentParams`). |
22
- | `fromRouteName` | `string` | Source route name (`fromRouteName`). |
23
- | `actionId` | `string` | Id of the action that triggered the render. |
24
- | `actionUrl` | `string \| URL` | URL the action was submitted from. |
25
- | `actionResult` | `unknown` | The action's return value. |
26
- | `formData` | `FormData` | Form data from a form action. |
27
- | `env` | `TEnv` | Bindings surfaced as `env`. |
28
- | `vars` | `VarsInit` | Values the predicate reads through `get()`. With `ppr`, these model pre-handler middleware state. |
29
- | `onError` | `OnErrorCallback` | Receives an error the predicate throws (the gate reports it with phase `"rendering"`). |
30
- | `ppr` | `boolean` | Model a `ppr` route, where the predicate runs before route handlers and the cache lookup. |
31
-
32
- ### Returns — `RunTransitionWhenResult<TEnv>`
33
-
34
- | Field | Type | Meaning |
35
- | ------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- |
36
- | `kept` | `boolean` | True when the transition applies (the predicate did not return `false`, or there is no `when`). |
37
- | `dropped` | `boolean` | `!kept`. |
38
- | `whenContext` | `TransitionWhenContext \| undefined` | The context the predicate received; `undefined` when the config has no `when`. |
39
- | `ctx` | `RequestContext<TEnv>` | The underlying request context, for extra assertions. |
5
+ Both primitives are synchronous and run the router's own evaluation code on arguments you seed, so a predicate sees the same fields it sees at runtime. Neither renders anything: they answer "would this navigation hold?" and "would this `clientUrls()` loader re-run?". Both predicates run in the browser at runtime.
6
+
7
+ ## runTransitionWhen(when | config, opts?)
8
+
9
+ Builds the `TransitionWhenContext` the browser builds and evaluates the predicate through the router's own browser code (`browser/transition-when.ts`): a throw counts as `false` and is logged with `console.error`, exactly as at navigation time. Accepts the predicate or a whole `transition()` config (a config without `when` always applies).
10
+
11
+ ### Options — `RunTransitionWhenOptions`
12
+
13
+ Every field is optional. A location (`from` / `to`) is a URL string, a `URL`, or a partial `RouteLocation`: `{ url?, params?, routeName?, state? }`. A `state` given as location-state entries (`[Def(value)]`, what `Link` and `router.push` take) is stored the way a push stores it, so `Def.read(ctx.to)` reads it back; any other value is used as the raw `history.state`.
14
+
15
+ | Field | Type | Meaning |
16
+ | -------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | `kind` | `"push" \| "replace" \| "pop" \| "action" \| "revalidate"` | Defaults to `"action"` when `action` is given, `"push"` otherwise. |
18
+ | `from` | location | The committed location being left. Defaults to `http://localhost/`. |
19
+ | `to` | location | The destination. Defaults to `from`; always `from` for `"action"` and `"revalidate"`. |
20
+ | `action` | imported action \| `string` \| `{ ref, formData, result, error }` | The triggering action (`isAction()` matches it; `action.id` is its id). Like `runClientRevalidate`, an imported action outside a built app needs its id passed as the string. |
21
+
22
+ ### Returns — `RunTransitionWhenResult`
23
+
24
+ | Field | Type | Meaning |
25
+ | ---------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
26
+ | `applied` | `boolean` | True when the navigation holds: the predicate returned anything but `false` and did not throw, or there is no `when`. |
27
+ | `gatedOff` | `boolean` | `!applied`: the navigation commits urgently with no view transition. |
28
+ | `context` | `TransitionWhenContext` | The context the predicate received. |
40
29
 
41
30
  ### Recipe
42
31
 
43
32
  ```ts
44
33
  import { it, expect } from "vitest";
45
- import { runTransitionWhen } from "@rangojs/router/testing";
46
- import type { TransitionConfig } from "@rangojs/router";
34
+ import {
35
+ runTransitionWhen,
36
+ withLocationStateKey,
37
+ } from "@rangojs/router/testing";
38
+ import { slideWhen } from "../src/transitions.js"; // a "use client" module
39
+ import { Slide } from "../src/location-states.js";
47
40
 
48
- const slide: TransitionConfig = {
49
- when: (c) => c.fromRouteName === "products",
50
- };
41
+ withLocationStateKey(Slide, "Slide");
51
42
 
52
- it("animates only when coming from the product list", () => {
43
+ it("animates unless the Link pushed { animate: false }", () => {
53
44
  expect(
54
- runTransitionWhen(slide, {
55
- request: "/products/1",
56
- fromRouteName: "products",
57
- }).kept,
45
+ runTransitionWhen(slideWhen, {
46
+ from: { url: "/photos/1", params: { id: "1" }, routeName: "photo" },
47
+ to: { url: "/photos/2", params: { id: "2" }, routeName: "photo" },
48
+ }).applied,
58
49
  ).toBe(true);
59
- expect(runTransitionWhen(slide, { request: "/products/1" }).dropped).toBe(
60
- true,
61
- );
50
+
51
+ const { gatedOff, context } = runTransitionWhen(slideWhen, {
52
+ from: "/photos/1",
53
+ to: { url: "/photos/2", state: [Slide({ animate: false })] },
54
+ });
55
+ expect(Slide.read(context.to)).toEqual({ animate: false });
56
+ expect(gatedOff).toBe(true);
62
57
  });
63
58
  ```
64
59
 
60
+ ### Known limits (Vitest)
61
+
62
+ - A Vitest project runs neither the Vite plugin's hoist nor its validation. An inline `when` in a `urls()` file stays a plain function there, and a `"use client"` import is the plain function outside the RSC graph, so a server function is not told apart and is accepted. The loud error for an invalid `when` belongs to dev startup, the build and HMR.
63
+ - `renderRoute` decides each commit with production's decision code: `router.navigate()` (and `useRouter().push/replace`, `<Link>`) as `kind: "push"` / `"replace"`, `router.refresh()` as `kind: "revalidate"`, and `navigate(url, { transition: false })` without calling the predicate. Pop, action and `clientUrls()` optimistic-swap decisions are e2e territory.
64
+
65
65
  ## runClientRevalidate(fn | fn[], opts?)
66
66
 
67
67
  Evaluates one `clientUrls()` loader `revalidate()` predicate, or a chain in declaration order, through the production chain evaluator: the locked default, boolean short-circuit, soft verdicts (`{ defaultShouldRevalidate }`), and a throwing predicate deferring to the default all behave as in the browser. Returns the final `boolean` (the locked default when every predicate defers or throws).
@@ -111,7 +111,7 @@ it("revalidates only when the tab changes", () => {
111
111
  ## Caveats
112
112
 
113
113
  - Server-tree `revalidate()` predicates have no dedicated primitive: they are plain functions, so call them with a hand-built args object, or assert the post-action result at e2e.
114
- - `runTransitionWhen` proves the GATE decision only. Whether the browser actually runs a view transition is e2e territory (see `./e2e-parity.md`).
114
+ - `runTransitionWhen` proves the decision only. Whether the browser actually runs a view transition is e2e territory (see `./e2e-parity.md`).
115
115
  - Both are synchronous; a predicate returning a Promise is a bug in the predicate, not something to await here.
116
116
 
117
117
  ## See also
@@ -159,6 +159,7 @@ Without the plugin, wrap a function yourself with the call the transform emits,
159
159
  - An unseeded `ctx.use(loader)` REJECTS with a setup error — seed every dependency via `{ loaders: [[OtherLoader, data]] }`, matched by reference. Loaders are SEEDED, not executed (same as `runLoader`).
160
160
  - Same alias requirement as flight tests: without the `@rangojs/router -> index.rsc.ts` alias (see [`./setup.md`](./setup.md)), a handler reading `getRequestContext()`/`cookies()` hits the throwing out-of-react-server stub. `renderHandler` detects this and REJECTS with a setup error naming `rangoTestAliases` (it is not captured on `thrown`).
161
161
  - A `throw redirect()` is captured on `thrown` (with `tree` undefined, since it produced a `Response`) — assert on `thrown`/`response`, no try/catch needed.
162
+ - Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. This react-server project runs with `NODE_ENV=production`, so an unkeyed `ctx.setLocationState(Flash(value))` does not throw; the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix; `{ [Flash.__rsc_ls_key]: value }` works for any key).
162
163
  - No hydration and no interaction — for clicks, forms, and navigation use e2e.
163
164
  - `renderHandler` runs a handler FUNCTION `(ctx) => rsc`; for a plain ELEMENT `<Page/>` use `renderServerTree` (see [`./server-tree.md`](./server-tree.md)).
164
165
  - A handler that calls a `"use cache"` function runs UNCACHED unless you seed `cacheStore` (and `cacheProfiles` for a named profile). With nothing seeded the runtime bypasses to the live body and warns once under the test runner. With a store seeded, the body runs once across renders and a render after the background write reads the store — see [A `"use cache"` hit](#a-use-cache-hit), including `rangoUseCacheTransform()`, without which Vitest leaves the directive unwrapped.
@@ -135,6 +135,8 @@ expect(silver.shellStatus).toBe("MISS"); // its own capture, never gold's shell
135
135
  const gold = await serveShellRequest(router, "/pricing", tier("gold"));
136
136
  expect(gold.shellStatus).toBe("HIT");
137
137
  expect(gold.key).toBe(shellCacheKey("/pricing", undefined, "tier:gold"));
138
+ // "localhost/pricing:shell|key%3Atier%253Agold": the key() result is
139
+ // namespaced as production stores it (issue #975).
138
140
  expect(await gold.readEntry()).not.toBeNull();
139
141
  ```
140
142
 
@@ -151,6 +153,18 @@ expect(result.key).toBe(
151
153
  );
152
154
  ```
153
155
 
156
+ A store `keyGenerator` result that partitions the record partitions the shell too, an enclosing `cache({ store })` on another store included (issue #974). Pass those results as `generated`, with any `key()` results as `keys`:
157
+
158
+ ```ts
159
+ // createRouter({ cache: { store: localeStore } }), where localeStore's
160
+ // keyGenerator returns `${defaultKey}|${locale}`
161
+ expect(result.key).toBe(
162
+ shellCacheKey("/pricing", undefined, {
163
+ generated: ["doc:localhost/pricing|de"],
164
+ }),
165
+ );
166
+ ```
167
+
154
168
  A partial request has no HTML step, so its `key` is the URL's key without a partition: read a partitioned route's entry from a document request's result.
155
169
 
156
170
  ## Handles and loader data
@@ -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`).
@@ -123,6 +123,7 @@ and `getRequestContext().theme` are guarded like `cookies()`:
123
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
124
  | a route with no `ppr` or `cache()` above it, or middleware | Allowed: rendered per request |
125
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 |
126
127
 
127
128
  The theme getters are read-only and non-enumerable: `{ ...ctx }` and
128
129
  `Object.assign({}, ctx)` do not carry `theme`, and assigning it throws.
@@ -133,10 +134,13 @@ write there; call it from middleware.
133
134
  Read the theme where it is per request instead:
134
135
 
135
136
  - `useTheme()` in a client component. The `<html>` class is right before
136
- paint (the theme script sets it). On a `ppr` HIT the shell carries the
137
- no-cookie default (`defaultTheme`), whoever captured it: a visitor with no
138
- stored theme sees the default, and one with a stored theme sees it after
139
- hydration.
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.
140
144
  - A live loader (no `ssr: false`). Loaders do not get `ctx.theme` or
141
145
  `ctx.setTheme`; read the cookie directly. A live loader runs on every
142
146
  request, HITs included, and on a `ppr` route needs `loading()` or an inline
@@ -339,3 +339,11 @@ the value at click time; `ctx.setLocationState(entry)` attaches state from a
339
339
  handler; `redirect(url, { state })` carries it through a redirect; and
340
340
  `ProductPreview.read()` / `.write(value)` / `.delete()` access the current
341
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.
@@ -229,13 +229,32 @@ export const ProductLoader = createLoader(async (ctx) => getProduct(ctx));
229
229
  per-request values (cookies, headers) are not reflected in the cache key. Without
230
230
  this guard, one user's data would be served to another.
231
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
+
232
238
  Extract the value before the cached function and pass it as an argument:
233
239
 
234
240
  ```typescript
235
241
  const locale = cookies().get("locale")?.value ?? "en";
236
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);
237
247
  ```
238
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
+
239
258
  `ctx.get()` of a **non-cacheable variable** (`createVar({ cache: false })`, or
240
259
  a value written with `ctx.set(key, value, { cache: false })`) throws the same
241
260
  way, whether it goes through `getRequestContext().get()` or a `ctx` passed in
@@ -259,17 +278,20 @@ Ordinary (cacheable) variables stay readable. Calling the cached function from
259
278
  a loader does not exempt it: the loader re-runs on every request, but the
260
279
  cached body does not.
261
280
 
262
- The non-cacheable variable guard does not reach into LOADER bodies consumed
263
- inside the cached function (`await ctx.use(loader)`): loaders always run
264
- fresh, so their variable reads are exempt — but the CONSUMED VALUE is captured
265
- into the shared cache entry like any other computed data. (`cookies()` and
266
- `headers()` still throw there: that guard follows the cached body's whole
267
- async chain, loaders included.) Same rule across `cache()` and the PPR shell:
268
- handler/cached-scope consumption = baked copy, client-side `useLoader` = live
269
- (the consumption-lane rule, `/rango` → Invariants). The PPR shell is stricter
270
- about the loader's own reads: during a capture, `cookies()`, `headers()`, and a
271
- `{ cache: false }` variable read inside a handler-consumed loader refuse the
272
- capture.
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.
273
295
 
274
296
  ### Side-Effect Guards
275
297
 
@@ -532,11 +554,35 @@ on a navigation or action), not a cache bust.
532
554
  A route `cache()` entry stores the tags of the `"use cache"` reads inside it,
533
555
  so `updateTag()` of one evicts the enclosing entry and any `ppr` shell or
534
556
  document built from it. A loader's own `cache()` entry does the same for the
535
- reads in its body. The exception is a `"use cache"` function called inside
536
- another `"use cache"` function: its tags do not reach the enclosing entry, so
537
- invalidating one of them neither evicts the outer entry nor keeps this request
538
- from joining or storing an outer call that started before the invalidation
539
- ([#980](https://github.com/rangojs/rango/issues/980)).
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.
540
586
 
541
587
  ## Interaction with Other Caching
542
588
 
@@ -552,9 +598,8 @@ from joining or storing an outer call that started before the invalidation
552
598
  Inside a `ppr` shell, a `"use cache"` value that renders as shell material is
553
599
  pinned at capture time for the life of that shell (see `/ppr` → Pitfalls). A
554
600
  hole's own read (in a loader without `ssr: false` under `loading()` or an
555
- inline `<Suspense>`) returns the current entry, not the shell's copy, unless a
556
- loader the capture ran (an `ssr: false` loader, or one a handler awaits) read
557
- the same entry.
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.
558
603
 
559
604
  ## Dev Mode and tests
560
605