@rangojs/router 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -58,7 +58,8 @@ cache({ ttl: 60, swr: 300 }, () => [
58
58
  not stored: it renders on every request, hits included, with its parallels and
59
59
  loaders, so a per-request shell or a header it writes stays live. On a `ppr`
60
60
  route the whole chain bakes into the shell, so there `cache()` covers the whole
61
- chain.
61
+ chain, and a shell never outlives the route `cache()` entry it was captured
62
+ from (it is fresh and served no longer than that entry; `/ppr`).
62
63
 
63
64
  ```typescript
64
65
  layout(AccountShell, () => [
@@ -89,8 +90,12 @@ path, not only ProductChrome.
89
90
 
90
91
  The consumer rule: **want it cached? render it inline. Want it live? put it in a
91
92
  loader and read it with `useLoader()` in a client component.** Anything read
92
- with `cookies()`, `headers()`, or a non-cacheable variable belongs in a loader
93
- (loaders always run fresh). Reading it directly in a cached handler throws;
93
+ with `cookies()`, `headers()`, `ctx.request.headers`, or a non-cacheable
94
+ variable belongs in a loader:
95
+ a route `cache()` does not store loader values, so a loader runs on every
96
+ request. A loader bound with its own `cache()` is stored, so without a `key()`
97
+ its miss fails; any `key()` switches that check off, so it must include the
98
+ value (see `/loader` → "Cache Key"). Reading it directly in a cached handler throws;
94
99
  awaiting a loader with `ctx.use()` and rendering the result in a cached handler
95
100
  silently bakes per-request data into the shared entry (see "Cache purity &
96
101
  tainted objects" below).
@@ -163,9 +168,20 @@ cache(
163
168
  — document requests, client navigations, and intercept navigations are cached
164
169
  separately. The search part honors `cache.searchParams` (below).
165
170
  - **`key`** is a full override: it bypasses the default key, the store's
166
- `keyGenerator`, and the search-param filter.
171
+ `keyGenerator`, and the search-param filter. The result is stored
172
+ namespaced (`key:` plus its URI encoding), so whatever it returns, request
173
+ input included, never names another route's default-keyed record or a
174
+ nested partition's. A nested `cache()` keys its records within that
175
+ partition (see "Keys nest" under "Nested Cache Boundaries"). On a `ppr`
176
+ route the same key (or, without one, the store's `keyGenerator`)
177
+ partitions the PPR shell: each partition captures and serves its own shell
178
+ (`/ppr`, "Request-partitioned shells").
167
179
  - **`condition`** returning `false` skips both the cache read and write for that
168
- request (the boundary renders live).
180
+ request (the boundary renders live). It gates every `cache()` nested under
181
+ it too: an inner boundary caches only when every enclosing `condition()`
182
+ allows it.
183
+ - **`tags`** tag this boundary's records and those of every `cache()` nested
184
+ under it, so `updateTag()` of an outer tag evicts the inner records too.
169
185
  - `cache(() => [...])` with no options uses the store defaults.
170
186
  - `cache(false, () => [...])` disables caching for a subtree (see "Nested Cache
171
187
  Boundaries").
@@ -221,15 +237,28 @@ function CampaignBanner() {
221
237
 
222
238
  Each tag attaches to one entry, so know which entry you are tagging:
223
239
 
224
- - Forms 1 and 2 tag the `cache()` segment entry. They are the only way to tag
225
- it: tags recorded by a `"use cache"` function (form 3) stay on that
226
- function's entry and do not attach to an enclosing `cache()` segment entry.
227
- - Form 3 tags the `"use cache"` entry (plus any profile `tags`).
240
+ - Forms 1 and 2 tag the `cache()` segment entry, and the entries of every
241
+ `cache()` nested under it (see "Conditions and tags inherit").
242
+ - Form 3 tags the `"use cache"` entry (plus any profile `tags`), and every
243
+ `"use cache"` entry that calls that function: the outer entry bakes the
244
+ inner value, so it carries the inner tags.
228
245
  - Form 4 tags the request's `ppr` shell entry (`/ppr`) or `/document-cache`
229
- entry, with no `"use cache"` needed. Called inside a `cache()` segment it
230
- still tags the document/shell, not the segment entry. On a route that is
231
- neither `ppr` nor document-cached, nothing reads the tag — a silent no-op, so
232
- do not expect a bare `cacheTag()` to tag an ordinary uncached page.
246
+ entry, with no `"use cache"` needed. On a route that is neither `ppr` nor
247
+ document-cached and has no `cache()`, nothing reads the tag — a silent
248
+ no-op, so do not expect a bare `cacheTag()` to tag an ordinary uncached page.
249
+ - A route `cache()` entry also carries the tags its own content recorded when
250
+ it was written: form 4 calls and form 3 reads in the handlers and server
251
+ components it covers. `updateTag()` of such a tag drops the entry, and a HIT
252
+ re-records the tags, so a ppr shell or document built from the HIT stays
253
+ evictable. `loading()` subtrees count too: a HIT replays their output. A
254
+ loader's tags reach the entry only when a handler consumes its value
255
+ (`await ctx.use(Loader)`); a loader nobody reads on the server runs per
256
+ request and stays off.
257
+ - A loader with its own `cache()` stores the tags its body recorded (form 4
258
+ calls and form 3 reads, including those of the loaders it reads with
259
+ `ctx.use()`) next to its `cache({ tags })`. They invalidate the loader's
260
+ entry, and a HIT records them again, so a route `cache()` entry, ppr shell
261
+ or document built over the HIT carries them.
233
262
 
234
263
  Invalidate with one of two server-only verbs (both variadic, imported from
235
264
  `@rangojs/router`):
@@ -258,6 +287,28 @@ function onProductsChanged() {
258
287
  | `updateTag(...tags)` | awaitable (`Promise<void>`) | server actions | immediate; next read is fresh |
259
288
  | `revalidateTag(...tags)` | background (`void`) | route handlers / webhooks | background (non-blocking); next read re-renders fresh |
260
289
 
290
+ The request that calls either verb reads its own writes. Before the call
291
+ returns, each built-in store marks the tags as invalidated for the rest of that
292
+ request, so an action that calls `revalidateTag()` and then renders gets fresh
293
+ `"use cache"` and `cache()` reads, as with `await updateTag()`. What
294
+ `revalidateTag()` does not wait for is the durable write (the KV marker, the
295
+ tag purge, Vercel's `expireTag`): other requests see the invalidation once it
296
+ lands.
297
+
298
+ Work that started before the invalidation, in this request or another one,
299
+ is not stored after it. A `"use cache"` call, a stale entry's background
300
+ refresh, a loader's own `cache()`, a route `cache()` render or a
301
+ document-cache render that read its data before one of its tags was
302
+ invalidated still returns what it read, but its store write is skipped:
303
+ every store stamps an entry when it is written, so the old value would be
304
+ served as newer than the invalidation. The next read runs it again. A
305
+ `"use cache"` call already running when one of its tags is invalidated is
306
+ not joined by later calls either. Its tags include those of the
307
+ `"use cache"` functions it calls. Another isolate's invalidation is caught
308
+ through the store's markers (`CFCacheStore` with KV, `VercelCacheStore`); on
309
+ a KV-less `CFCacheStore` only this isolate's invalidations are, and ttl+swr
310
+ bounds the rest.
311
+
261
312
  Both must run inside a request (action, handler, middleware, response route).
262
313
  Called from a queue consumer or cron job, there is no request context, so they
263
314
  warn and invalidate nothing. `updateTag()` rejects when a store's durable write
@@ -383,8 +434,18 @@ defaults (ttl 60 when the store sets none); it does **not** inherit the options
383
434
  of an enclosing `cache()` boundary. Inside `loader()` only the direct form
384
435
  `cache({...})` is valid — the wrapper form `cache(opts, () => [...])` throws.
385
436
  Handle pushes from the loader body (`Meta`, `Breadcrumbs`) are stored with the
386
- value and replayed on every hit, stale included. See `/loader` for the full
387
- loader reference.
437
+ value and replayed on every hit, stale included. The tags the body records
438
+ (`cacheTag()`, `"use cache"` reads, and those of the loaders it reads) are
439
+ stored with it too: `updateTag()` of one drops the entry, as `cache({ tags })`
440
+ does. See `/loader` for the full loader reference.
441
+
442
+ The entry's default key is the loader, host, path and params. It does not
443
+ inherit an enclosing `cache()` boundary's `key()` either, so it is shared
444
+ across users: a body that reads `cookies()`, `headers()`, `ctx.request.headers`
445
+ or a non-cacheable variable fails on a miss unless the loader `cache()` has a
446
+ `key()` or its store
447
+ a `keyGenerator`. Either one switches the check off, so it must itself include
448
+ the value (`/loader` → "Cache Key").
388
449
 
389
450
  ## Global Cache Configuration
390
451
 
@@ -537,8 +598,9 @@ const router = createRouter<AppBindings>({
537
598
  | Cold colo | MISS | HIT | Serve from KV, promote to L1 |
538
599
  | First render | MISS | MISS | Render, write to both L1 + KV |
539
600
 
540
- KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
541
- are only cached in L1.
601
+ KV entries require `expirationTtl >= 60s`. Short-lived data entries (< 60s total
602
+ TTL) are only cached in L1. A PPR shell is always written to KV, a short one
603
+ with the 60 s minimum; its reads still expire it at its own deadline.
542
604
 
543
605
  ### Resilience & latency budgets
544
606
 
@@ -558,6 +620,76 @@ read. A non-finite value (e.g. `Number(env.UNSET)`) falls back to the default.
558
620
  The tag-invalidation marker reads inherit these same budgets and **fail open** on
559
621
  a KV timeout — the entry is served rather than wrongly treated as invalidated.
560
622
 
623
+ A PPR shell HIT reads its entry prelude-first: the body budget (or the KV budget
624
+ on a KV read, counted from opening the value) covers only the entry's head and
625
+ prelude, the tag-marker read runs alongside the prelude read, and the capture
626
+ snapshot behind them is read off the commit path, bounded by `kvReadTimeoutMs`
627
+ (at least 1 s: the tail cannot render without it). A HIT whose snapshot read
628
+ times out, or whose snapshot is truncated or corrupt (also evicted), cannot
629
+ replay the captured segment record, so it degrades: the entry is replaced and
630
+ recaptured and the page reloads once into a cache-miss render (`/ppr`). The
631
+ snapshot holds only what a HIT reads: every HIT replays the handler layer from
632
+ the captured segment record and serves an `ssr: false` loader's baked
633
+ container, so no `"use cache"` or loader `cache()` item is stored in it. A
634
+ live hole, and any loader that runs on the HIT, reads those from the store
635
+ under their own ttl/swr and tags.
636
+
637
+ **Shell memo.** After a shell read, the same isolate serves that key's next
638
+ HITs from memory for `memo.shellMs` (default 2000; `{ shellMs: 0 }` turns it
639
+ off), with no Cache API or KV read. The tag-marker check still runs on every
640
+ HIT, and the isolate that runs `updateTag()`/`revalidateTag()` drops its own
641
+ copies, so that request and later ones on the same isolate never get the
642
+ invalidated shell. What another isolate can serve for up to one window: the
643
+ previous capture of a key another isolate just recaptured, and, without KV in
644
+ purge mode, a purged shell (the purge reaches the stored entry, not other
645
+ isolates' memos). `memo.shellMaxBytes` (default 16 MiB, shared by every
646
+ `CFCacheStore` in the isolate) caps what it holds; only fresh shells are kept.
647
+
648
+ **Tag-marker memo.** With KV, PPR shell reads check their tag markers through
649
+ a per-isolate memo, stale-while-revalidate: a marker read within
650
+ `memo.markerFreshMs` (default 1000) is used as is, one within
651
+ `memo.markerMaxStaleMs` (default 10000) is used while a background read
652
+ refreshes it, an older one waits for KV. The isolate also remembers each
653
+ shell's tag names and starts those marker reads with the Cache API match
654
+ (`ppr.tags` are known up front). `cache()`, `"use cache"` and response entries
655
+ keep reading their markers. `{ markerFreshMs: 0 }` turns the value memo off.
656
+
657
+ **The fresh-reads cookie.** A response whose request ran `updateTag()` or
658
+ `revalidateTag()` sets `<state cookie prefix>-fresh` (`rango-state-fresh` by
659
+ default, shared by every router on the host with that prefix; `HttpOnly`,
660
+ `SameSite=Lax`, `Path=/`, `Secure` on https, `Max-Age` 11 s with KV and 3 s
661
+ without at the defaults: the longest memo staleness plus 1 s). It is strictly
662
+ functional: its value is `1`, it identifies no one, and it only sends that
663
+ browser's reads past the memos. The same user's requests that carry it skip
664
+ both memos on every isolate, so a mutating user never sees a memoized shell or
665
+ marker from before their write. What they read past the memos is the store's
666
+ own consistency. `VercelCacheStore`: `expireTag` is global (about 300 ms), so
667
+ they read fresh in any region. `CFCacheStore` with KV: the shell's tag check
668
+ reads that colo's marker (its Cache API copy when `tagCacheTtl` is set, then
669
+ KV), so in the colo that ran the mutation they read fresh, while a request
670
+ another colo serves can still get the old shell until KV propagates the
671
+ marker there (up to about 60 s) and that colo's cached marker (`tagCacheTtl`)
672
+ expires. `CFCacheStore` without KV in purge mode (`tagPurge`) has no markers:
673
+ the purge is what removes the shell, and they see it once it has reached the
674
+ colo serving them. Other users can see a memoized shell or marker for up to
675
+ `markerMaxStaleMs` (with KV) or the shell window (`VercelCacheStore` in
676
+ another region, `CFCacheStore` in KV-less purge mode, where another isolate's
677
+ memo can keep serving the purged shell). Set `{ shellMs: 0, markerFreshMs: 0 }` where every user's next
678
+ request must see an invalidation (with KV, `{ shellMs: 0 }` alone still leaves
679
+ the marker memo); that also means no cookie. The cookie is not set when the
680
+ invalidation runs after the response headers were sent (a streaming loader or
681
+ render; dev warns): invalidate in a server action, route handler or
682
+ middleware. Any client can send it, which only makes its own requests read
683
+ the store.
684
+
685
+ ```typescript
686
+ new CFCacheStore({
687
+ ctx,
688
+ kv: env.CACHE_KV,
689
+ memo: { shellMs: 5000, markerFreshMs: 500 },
690
+ });
691
+ ```
692
+
561
693
  ```typescript
562
694
  new CFCacheStore({
563
695
  ctx,
@@ -623,13 +755,17 @@ the store adds SWR, tag invalidation, and the family split on top of it.
623
755
  import { getCache, waitUntil } from "@vercel/functions";
624
756
  import { VercelCacheStore } from "@rangojs/router/cache";
625
757
 
758
+ // A per-deploy namespace: Vercel does not reconcile entries across deploys.
759
+ // One handle per process: the store keeps its PPR shell and tag-marker
760
+ // memos per handle.
761
+ const runtimeCache = getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID });
762
+
626
763
  const router = createRouter({
627
764
  document: Document,
628
765
  urls: urlpatterns,
629
766
  cache: () => ({
630
767
  store: new VercelCacheStore({
631
- // A per-deploy namespace: Vercel does not reconcile entries across deploys
632
- cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
768
+ cache: runtimeCache,
633
769
  waitUntil,
634
770
  defaults: { ttl: 60, swr: 300 },
635
771
  }),
@@ -637,6 +773,22 @@ const router = createRouter({
637
773
  });
638
774
  ```
639
775
 
776
+ The store keeps the same PPR shell memo as `CFCacheStore` (`memo.shellMs`,
777
+ default 2000; `memo.shellMaxBytes`, default 16 MiB) and the same tag-marker
778
+ memo (`memo.markerFreshMs`, default 300; `memo.markerMaxStaleMs`, default
779
+ 2000), one per `cache` handle: create the handle once, as above, or the memos
780
+ never hit. The request that runs `updateTag()`/`revalidateTag()` misses on
781
+ the entries carrying those tags before `expireTag` lands. A shell read's
782
+ marker check sees the invalidation in the process that ran it once its
783
+ markers are written, and in the rest of the region once their marker memo
784
+ refreshes. The tag markers are a regional `cache.set` (only
785
+ `expireTag` is global), so another region serves a shell it memoized before
786
+ the invalidation until its window passes, where without the memo `expireTag`
787
+ removes it within about 300 ms; a platform `expireTag` issued outside rango is
788
+ likewise seen when the window passes. The fresh-reads cookie (`Max-Age` 3 s)
789
+ sends the mutating user's next requests past both memos, and `expireTag` is
790
+ global, so they read fresh in any region.
791
+
640
792
  Writes over 2 MB (the platform limit, `maxItemBytes`) are skipped, and tags
641
793
  beyond the per-item cap are dropped with a warning. The official client swallows
642
794
  `expireTag` failures, so treat `updateTag()` on Vercel as best-effort. See
@@ -649,18 +801,52 @@ cached handler is **frozen into the shared cache entry** and served to every
649
801
  subsequent visitor. To stop one user's request-scoped data from leaking to
650
802
  another, request-scoped APIs are guarded inside a cache scope:
651
803
 
652
- | Inside a `cache()` boundary | Behavior |
653
- | ---------------------------------------------------------------------------------- | --------------------------------------------------- |
654
- | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
655
- | Response writes: `ctx.headers.set()`, `setCookie()`, `setStatus()`, `onResponse()` | **throws** — response side effects lost on a hit |
656
- | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
657
- | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
658
- | Any of the above **inside a registered loader** (`loader(...)`) | **allowed** — loaders always run fresh |
804
+ | Inside a `cache()` boundary | Behavior |
805
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------- |
806
+ | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
807
+ | `ctx.request.headers`, `getRequestContext().cookie()` / `.cookies()` | **throws** on read, like `headers()` / `cookies()` |
808
+ | Response writes: `ctx.headers.set()`, `setCookie()`, `setStatus()`, `onResponse()` | **throws** — response side effects lost on a hit |
809
+ | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
810
+ | `ctx.theme`, `getRequestContext().theme` | **throws** — the theme is the visitor's cookie |
811
+ | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
812
+ | Any of the above **inside a registered loader** (`loader(...)`) | **allowed** — loaders always run fresh |
813
+ | Any read in the boundary's own `key()`, `keyGenerator`, `condition()` or `tags()` | **allowed** — it picks or labels the entry, never rendered |
659
814
 
660
815
  A loader body invoked from a handler with `await ctx.use(Loader)` may read
661
- `cookies()`/`headers()`, but its response writes still throw: on a hit the
816
+ `cookies()`/`headers()`/`ctx.request.headers`, but its response writes still throw: on a hit the
662
817
  handler is skipped, so that loader never runs.
663
818
 
819
+ `ctx.request.clone()` is guarded the same way. `fetch(ctx.request)` and
820
+ `new Request(ctx.request)` don't throw, but the guard can't see through them:
821
+ a fetch forwards the visitor's `Cookie` and `Authorization`, so its response
822
+ is per visitor and must not be rendered inside the boundary. A handler that
823
+ must render a header its `key()` partitions by reads a copy middleware set:
824
+
825
+ ```tsx
826
+ const Tier = createVar<string>();
827
+ const tierOf = (ctx: { request: Request }) =>
828
+ ctx.request.headers.get("x-tier") === "gold" ? "gold" : "silver";
829
+
830
+ middleware(
831
+ async (ctx, next) => {
832
+ ctx.set(Tier, tierOf(ctx)); // middleware runs outside the boundary
833
+ return next();
834
+ },
835
+ () => [
836
+ cache({ ttl: 300, key: (ctx) => `tier:${tierOf(ctx)}` }, () => [
837
+ path("/pricing", (ctx) => <Pricing tier={ctx.get(Tier)} />),
838
+ ]),
839
+ ],
840
+ );
841
+ ```
842
+
843
+ A loader bound with its **own** `cache()` (`loader(Def, () => [cache({...})])`)
844
+ stores its value, and its key does not inherit the route's. A miss whose body
845
+ read `cookies()`, `headers()`, `ctx.request.headers` or a non-cacheable
846
+ `ctx.get()` fails unless the
847
+ binding has a `key()` or its store a `keyGenerator`, which must then include
848
+ the value (`/loader` → "Cache Key").
849
+
664
850
  **Tainted objects.** Request-scoped objects (`ctx`, `env`, `request`) carry an
665
851
  internal taint symbol so they are excluded from `"use cache"` cache keys. The
666
852
  `cache()` scope is tracked in async-local state and deliberately ends inside
@@ -680,10 +866,15 @@ is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
680
866
  uses (set a ctx var, make a routing decision); never render its result inside a
681
867
  cached handler.
682
868
 
683
- This is the **consumption-lane rule**, and it holds identically for every
684
- shared artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`):
685
- handler consumption = baked copy with identity reads permitted; client-side
686
- `useLoader` = live. It is stated once in `/rango` → Invariants.
869
+ This is the **consumption-lane rule**, and it holds for every shared
870
+ artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`): handler
871
+ consumption = baked copy; client-side `useLoader` = live. The tiers differ on
872
+ identity reads inside a handler-consumed loader: `cache()` permits them (the
873
+ leak above); `"use cache"` throws when the cached function is the first to
874
+ read the loader in the request, because the loader body then runs inside it
875
+ and its value is part of the result (a loader already started elsewhere
876
+ returns its value unchecked, and that value is stored); a PPR shell capture
877
+ refuses them and the route stays uncached. It is stated once in `/rango` → Invariants.
687
878
 
688
879
  ```typescript
689
880
  // WRONG — throws: cookies() read directly in a cached handler
@@ -729,8 +920,11 @@ See `/cache-guide` for the full decision guide and the `cache()` vs `"use cache"
729
920
 
730
921
  ## Nested Cache Boundaries
731
922
 
732
- An inner boundary overrides the settings of the outer one for its subtree;
733
- `cache(false)` turns caching off for a subtree:
923
+ An inner boundary overrides `ttl`, `swr` and `store` for its subtree;
924
+ `cache(false)` turns caching off for a subtree. The options that decide
925
+ whether and where a record may be shared build up instead: `key` composes
926
+ (see below), every enclosing `condition()` must allow the inner boundary, and
927
+ the inner records carry the enclosing `tags` too:
734
928
 
735
929
  ```typescript
736
930
  cache({ ttl: 300 }, () => [
@@ -746,6 +940,130 @@ cache({ ttl: 300 }, () => [
746
940
  ]);
747
941
  ```
748
942
 
943
+ On a `ppr` route the opt-out also means no shell: `cache(false)`, or a
944
+ `condition()` that returns false, renders that route (or that request) like a
945
+ cache miss, with no shell served and none captured. A `ppr` route under a
946
+ layout's `cache()` therefore cannot opt out of that `cache()` and still get a
947
+ shell (`/ppr`).
948
+
949
+ ### Conditions and tags inherit
950
+
951
+ ```typescript
952
+ cache(
953
+ {
954
+ condition: (ctx) => !ctx.request.headers.has("x-preview"),
955
+ tags: ["catalog"],
956
+ },
957
+ () => [
958
+ cache({ ttl: 60, tags: ["prices"] }, () => [
959
+ path("/prices", PricesPage, { name: "prices" }),
960
+ ]),
961
+ ],
962
+ );
963
+ ```
964
+
965
+ - A preview request renders the prices page live: the outer `condition()` bypasses
966
+ the inner record's read and write, and a `ppr` route's shell. Conditions
967
+ combine with AND, through a `cache(false)` with a `cache()` re-enabled
968
+ below it too.
969
+ - The prices record carries `catalog` and `prices` (static and function
970
+ `tags` alike), so `updateTag("catalog")` evicts it, and the `ppr` shells
971
+ and documents built from it.
972
+ - `cache(false)` is unchanged: it caches nothing below it until a `cache()`
973
+ re-enables caching.
974
+ - A loader's own `cache()` and `"use cache"` are separate layers: they take
975
+ neither the route's `condition()` nor its `tags`, nor its `key()`
976
+ partition.
977
+
978
+ Before issue #974, only the innermost boundary's `condition` and `tags`
979
+ applied: a request an outer `condition()` refused still read and wrote the
980
+ inner record, and `updateTag()` of an outer tag left it in place.
981
+
982
+ ### Keys nest: a partition covers the whole subtree
983
+
984
+ A `key()` partitions every record under its `cache()`. A nested `cache()`
985
+ keys its records within that partition, so a request in one partition never
986
+ HITs another partition's inner record:
987
+
988
+ ```typescript
989
+ const tier = (ctx) =>
990
+ ctx.request.headers.get("x-tier") === "gold" ? "gold" : "silver";
991
+
992
+ cache({ ttl: 300, key: (ctx) => `tier:${tier(ctx)}` }, () => [
993
+ layout(TierLayout, () => [
994
+ // No key() of its own: the outer key() result, then this boundary's own
995
+ // default key ("key:tier%3Agold|doc%3Aexample.com%2Fpricing"), so
996
+ // /pricing and /faq keep their own records
997
+ cache({ ttl: 60 }, () => [
998
+ path("/pricing", PricingPage, { name: "pricing" }),
999
+ path("/faq", FaqPage, { name: "faq" }),
1000
+ ]),
1001
+
1002
+ // Its own key() composes with the outer one ("key:tier%3Agold|key:v%3Ab")
1003
+ cache(
1004
+ { ttl: 60, key: (ctx) => `v:${ctx.searchParams.get("v") ?? "a"}` },
1005
+ () => [path("/plans", PlansPage, { name: "plans" })],
1006
+ ),
1007
+ ]),
1008
+ ]);
1009
+ ```
1010
+
1011
+ Every `key()` result is stored as `key:` plus its URI encoding; a default
1012
+ key in a composed key is URI-encoded. The parts are joined by `|`, `key()`
1013
+ results first, outermost first:
1014
+
1015
+ | Boundary the route sits in | Record key |
1016
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1017
+ | a single keyed `cache()` | Its `key()` result, namespaced (`tier:gold` is stored as `key:tier%3Agold`) |
1018
+ | `cache({ ttl })`, no `key`, under a keyed one | The chain's `key()` results, then this boundary's default key (its store's `keyGenerator` result, else the default key), encoded |
1019
+ | `cache({ key })` under a keyed one | The chain's `key()` results |
1020
+ | deeper nesting | Composes the same way; only the innermost boundary's missing `key` adds its default key |
1021
+ | under a `cache({ store })` on another store | Also that store's `keyGenerator` result, one part per store: empty for a store that returns the default key while another's differs, none when all return it |
1022
+ | `cache(false)` | No caching, as before; a `cache()` re-enabled below it stays in the partition |
1023
+ | no `key` anywhere | The default key, or the store's `keyGenerator` result, as before |
1024
+
1025
+ - No `key()` result, whatever it returns, equals a default key or a
1026
+ composed key, and no two composed keys are equal: a namespaced result
1027
+ starts with `key:` and holds no `|`, a default key never starts with
1028
+ `key:`, and each part of a composed key is encoded, so the parts and their
1029
+ kinds are recoverable. Returning request input from `key()` cannot name
1030
+ another route's record (before issue #975 a header value
1031
+ `doc:example.com/pricing` returned raw named the pricing page's default-keyed
1032
+ record). A prefix (`tier:${value}`) is still worth it for readable keys,
1033
+ and normalizing to the values you serve keeps the partition count bounded.
1034
+ - An inner boundary without `key` keeps everything its default key tells
1035
+ apart (path, params, search, document vs navigation), so its routes never
1036
+ share a record, even when the outer `key()` names no route.
1037
+ - A boundary with its own `key()` skips the store's `keyGenerator`, as a
1038
+ single `key()` does.
1039
+ - Each `key()` runs once per request, however many boundaries and lookups use
1040
+ it.
1041
+ - A store's `keyGenerator` partitions the records of a `cache()` on that
1042
+ store without a `key()` of its own. A `cache({ store })` on another store
1043
+ nested under it keys its records by that `keyGenerator` result too, so a
1044
+ locale-partitioned outer boundary never shares an inner record across
1045
+ locales. With several such stores each keeps its position: one that
1046
+ returns the default key for a request adds an empty part while another's
1047
+ differs, and when all return it the key is unchanged. A `keyGenerator` must
1048
+ return the default key, not `""`, to leave a request unpartitioned: an
1049
+ empty result there fails key resolution, so the request renders uncached,
1050
+ and the router warns once naming the store. On the same store the inner
1051
+ boundary's own default key already carries it (or its own `key()`
1052
+ overrides it).
1053
+ - A response route's entry (`path.json()` and the other response routes) is
1054
+ keyed the same way, behind a `response:` prefix. A `ppr` route's shell is
1055
+ partitioned by the chain's `key()` results, namespaced the same way, since
1056
+ the shell key already carries the URL (plus the `keyGenerator` results
1057
+ above, and the store's for an inner boundary without `key`).
1058
+ - To share an inner cache across partitions, move it outside the keyed
1059
+ `cache()`.
1060
+
1061
+ Before issue #970, the innermost boundary alone decided the key: an inner
1062
+ `cache()` without `key` wrote under the default key and an inner `key()`
1063
+ dropped the outer partition, so a silver visitor could HIT what a gold
1064
+ visitor's request had cached. Before issue #974 an outer `cache({ store })`'s
1065
+ `keyGenerator` did not reach an inner boundary on another store.
1066
+
749
1067
  ## Custom Cache Store
750
1068
 
751
1069
  Create a dedicated store for specific routes:
@@ -765,6 +1083,16 @@ A per-boundary store becomes reachable by `updateTag()`/`revalidateTag()` only
765
1083
  once that boundary has been matched in the current process. For data you
766
1084
  invalidate by tag, prefer the app-level store.
767
1085
 
1086
+ If you implement `SegmentCacheStore` yourself, `invalidateTags(tags)` is called
1087
+ synchronously inside the invalidating request, and `revalidateTag()` does not
1088
+ await it. For that request to read its own writes, record the tags and the
1089
+ time as invalidated in request-scoped state your reads check (a `WeakMap`
1090
+ keyed by the object `getRequestContext()` returns) before the first `await`,
1091
+ then start the durable write. Only let that state turn hits into misses, and
1092
+ only for entries written at or before that time. A store that skips this
1093
+ still works, but the request that ran `revalidateTag()` can read invalidated
1094
+ entries until the durable write lands.
1095
+
768
1096
  ## Complete Example
769
1097
 
770
1098
  ```typescript
@@ -251,7 +251,7 @@
251
251
  },
252
252
  {
253
253
  "name": "testing",
254
- "description": "Test @rangojs/router apps — unit (loaders, middleware, server actions, handles, transition/clientUrls predicates), client components (renderRoute), RSC/Flight (renderServerTree, renderHandler), integration (dispatch), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader, middleware, action, or handler, setting up vitest for a Rango app, or testing a route end-to-end in dev and production.",
254
+ "description": "Test @rangojs/router apps — unit (loaders, middleware, server actions, handles, transition/clientUrls predicates), client components (renderRoute), RSC/Flight (renderServerTree, renderHandler, serveShellRequest for PPR shells), integration (dispatch), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader, middleware, action, or handler, setting up vitest for a Rango app, or testing a route end-to-end in dev and production.",
255
255
  "argumentHint": "[layer]",
256
256
  "path": "skills/testing/SKILL.md"
257
257
  },
@@ -134,7 +134,7 @@ flow into the generated route map, so `href()`, `ctx.reverse()` and
134
134
  | `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false }, use?)` — see below |
135
135
  | `loading()` | Route-level boundary around the optimistic render; inline `<Suspense>` at read sites keeps the destination's chrome visible while only the reads wait |
136
136
  | `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
137
- | `transition()` | Inside a `path()` use callback only (at most one). Data-only ViewTransition config — no `when`; same-route navs in a group already hold previous content without it |
137
+ | `transition()` | Inside a `path()` use callback only (at most one). ViewTransition config plus a browser-run `when` predicate (see `/view-transitions`); same-route navs in a group already hold without it |
138
138
  | `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
139
139
 
140
140
  At the top level of the builder only `path()`, `layout()` and `intercept()`
@@ -268,8 +268,9 @@ handler parity — `ctx.use(Meta)({ title })` pushes exactly like a handler
268
268
  push. Reads moved to `ctx.get(handle)`, gated behind `await ctx.rendered()`.
269
269
  Delivery follows the race model: pushes that settle before the handler barrier
270
270
  ride the SSR handle snapshot; later ones stream and apply post-hydration
271
- (document loads) or progressively (navigations). See `/loader` for the full
272
- contract.
271
+ (document loads) or progressively (navigations). On a `ppr` shell HIT a loader
272
+ without `ssr: false` never ran during the capture, so its pushes are never in
273
+ the shell and always apply client-side. See `/loader` for the full contract.
273
274
 
274
275
  ## `ssr: false` — the SSR-completeness opt-in
275
276
 
@@ -309,10 +310,14 @@ Constraints:
309
310
  - Under a `ppr` group route the flag is the bake lane, exactly as on a server
310
311
  route (`/ppr` → The loader lane rule).
311
312
 
312
- Every document load pays the flagged loader's latency before first byte —
313
- that is the point, but it is a real cost. Keep flagged loaders fast (existence
314
- checks before expensive fetches) and flag the loaders that need it, not the
315
- route.
313
+ Every document render that is not a `ppr` shell HIT pays the flagged
314
+ loader's latency before first byte — that is the point, but it is a real cost.
315
+ On a shell HIT the shell flushes first: a flagged loader whose value has no
316
+ promises is served from the shell and does not run (unless the capture marked
317
+ it to run: see `/ppr` → On a shell HIT), and one whose value
318
+ carries promises runs on every HIT to fill its holes. Keep flagged loaders
319
+ fast (existence checks before expensive fetches) and flag the loaders that
320
+ need it, not the route.
316
321
 
317
322
  Pinned end to end by dev + production e2e in the router repository.
318
323
 
@@ -168,6 +168,21 @@ family runs L1-only (each colo captures and serves its own shell). A tagged
168
168
  shell with neither `kv` nor `tagPurge` logs a warning once and expires only by
169
169
  ttl+swr. See `/caching` and `/ppr`.
170
170
 
171
+ Shell reads are stale-while-revalidate through per-isolate memos: a warm
172
+ isolate serves a shell it read within `memo.shellMs` (default 2000) from
173
+ memory, and with `kv` it checks tag markers through a memo that is used as is
174
+ for `memo.markerFreshMs` (default 1000) and while it refreshes in the
175
+ background up to `memo.markerMaxStaleMs` (default 10000). After
176
+ `updateTag()`/`revalidateTag()` the response sets the fresh-reads cookie
177
+ (`rango-state-fresh` by default), so the mutating user's next requests skip
178
+ the memos. That makes them fresh in the colo that ran the mutation; other
179
+ colos converge once KV propagates the marker and their cached marker
180
+ (`tagCacheTtl`) expires. In purge mode without `kv`, another isolate's memo
181
+ keeps serving a purged shell until `memo.shellMs` passes. Set
182
+ `memo: { shellMs: 0, markerFreshMs: 0 }` to remove the memo delay for every
183
+ user's next request; cross-colo KV propagation (and `tagCacheTtl`) still
184
+ applies. Details: `/caching` → "The fresh-reads cookie".
185
+
171
186
  ## Tracing on Workers
172
187
 
173
188
  `createCloudflareTracing()` from `@rangojs/router/cloudflare` emits the