@rangojs/router 0.26.6 → 0.28.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 (234) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-coordinator.d.ts +0 -4
  4. package/dist/types/browser/action-fence.d.ts +11 -3
  5. package/dist/types/browser/event-controller.d.ts +20 -1
  6. package/dist/types/browser/history-state.d.ts +7 -9
  7. package/dist/types/browser/intercept-utils.d.ts +23 -0
  8. package/dist/types/browser/navigation-store.d.ts +8 -6
  9. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  10. package/dist/types/browser/rsc-router.d.ts +1 -1
  11. package/dist/types/browser/segment-reconciler.d.ts +6 -4
  12. package/dist/types/browser/types.d.ts +2 -1
  13. package/dist/types/browser/wire-protocol.d.ts +25 -0
  14. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  15. package/dist/types/cache/cache-scope.d.ts +34 -22
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  18. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  19. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  20. package/dist/types/cache/segment-codec.d.ts +14 -0
  21. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  22. package/dist/types/cache/tag-invalidation.d.ts +10 -0
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +4 -6
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +4 -4
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +12 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +10 -20
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +35 -87
  38. package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
  39. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/revalidation.d.ts +6 -1
  44. package/dist/types/router/router-context.d.ts +9 -63
  45. package/dist/types/router/router-interfaces.d.ts +8 -14
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +93 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution.d.ts +1 -2
  52. package/dist/types/router/segment-wrappers.d.ts +11 -48
  53. package/dist/types/router/telemetry.d.ts +2 -0
  54. package/dist/types/router/types.d.ts +10 -0
  55. package/dist/types/rsc/full-payload.d.ts +2 -2
  56. package/dist/types/rsc/handler-context.d.ts +6 -6
  57. package/dist/types/rsc/json-route-result.d.ts +5 -9
  58. package/dist/types/rsc/request-stages.d.ts +203 -0
  59. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  60. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  61. package/dist/types/rsc/routine-plan.d.ts +1 -1
  62. package/dist/types/rsc/server-action.d.ts +2 -2
  63. package/dist/types/rsc/shell-capture-constants.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture.d.ts +132 -15
  65. package/dist/types/rsc/shell-serve.d.ts +37 -12
  66. package/dist/types/server/context.d.ts +9 -8
  67. package/dist/types/server/handle-store.d.ts +16 -0
  68. package/dist/types/server/render-pass.d.ts +72 -0
  69. package/dist/types/server/request-context.d.ts +24 -104
  70. package/dist/types/server/rewrite-trail.d.ts +2 -2
  71. package/dist/types/testing/dispatch.d.ts +35 -18
  72. package/dist/types/testing/serve-shell-request.d.ts +7 -0
  73. package/dist/types/types/cache-types.d.ts +6 -3
  74. package/dist/types/types/error-types.d.ts +9 -1
  75. package/dist/types/types/handler-context.d.ts +18 -7
  76. package/dist/types/types/segments.d.ts +1 -1
  77. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  78. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  79. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  80. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  81. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  82. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  83. package/dist/types/vite/discovery/state.d.ts +12 -0
  84. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  85. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  86. package/dist/types/vite/router-discovery.d.ts +8 -47
  87. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  88. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  89. package/dist/types/wire-params.d.ts +34 -0
  90. package/dist/vite/index.js +3809 -3710
  91. package/package.json +1 -1
  92. package/skills/cache-guide/SKILL.md +8 -5
  93. package/skills/caching/SKILL.md +37 -18
  94. package/skills/client-urls/SKILL.md +2 -1
  95. package/skills/document-cache/SKILL.md +18 -1
  96. package/skills/handler-use/SKILL.md +1 -1
  97. package/skills/intercept/SKILL.md +53 -13
  98. package/skills/layout/SKILL.md +11 -7
  99. package/skills/loader/SKILL.md +30 -13
  100. package/skills/middleware/SKILL.md +19 -1
  101. package/skills/migrate-nextjs/SKILL.md +5 -4
  102. package/skills/parallel/SKILL.md +5 -4
  103. package/skills/ppr/SKILL.md +19 -3
  104. package/skills/prerender/SKILL.md +39 -15
  105. package/skills/rango/SKILL.md +23 -11
  106. package/skills/server-actions/SKILL.md +51 -6
  107. package/skills/testing/predicates.md +1 -1
  108. package/skills/testing/response-routes.md +6 -4
  109. package/skills/testing/serve-shell-request.md +7 -7
  110. package/skills/use-cache/SKILL.md +46 -8
  111. package/skills/ux-architecture/SKILL.md +3 -2
  112. package/src/__internal.ts +7 -1
  113. package/src/browser/action-coordinator.ts +2 -13
  114. package/src/browser/action-fence.ts +11 -3
  115. package/src/browser/connection-warmup.ts +3 -1
  116. package/src/browser/event-controller.ts +42 -3
  117. package/src/browser/history-state.ts +15 -13
  118. package/src/browser/intercept-utils.ts +51 -0
  119. package/src/browser/navigation-bridge.ts +34 -30
  120. package/src/browser/navigation-client.ts +21 -33
  121. package/src/browser/navigation-store.ts +25 -27
  122. package/src/browser/navigation-transaction.ts +7 -11
  123. package/src/browser/partial-update.ts +8 -0
  124. package/src/browser/prefetch/cache.ts +2 -1
  125. package/src/browser/prefetch/fetch.ts +8 -16
  126. package/src/browser/prefetch/queue.ts +4 -4
  127. package/src/browser/react/NavigationProvider.tsx +2 -1
  128. package/src/browser/react/filter-segment-order.ts +2 -2
  129. package/src/browser/rsc-router.tsx +17 -2
  130. package/src/browser/segment-reconciler.ts +66 -53
  131. package/src/browser/server-action-bridge.ts +265 -127
  132. package/src/browser/types.ts +2 -1
  133. package/src/browser/wire-protocol.ts +41 -0
  134. package/src/cache/cache-key-utils.ts +11 -5
  135. package/src/cache/cache-runtime.ts +110 -50
  136. package/src/cache/cache-scope.ts +110 -64
  137. package/src/cache/cf/cf-cache-constants.ts +2 -2
  138. package/src/cache/cf/cf-cache-store.ts +486 -874
  139. package/src/cache/cf/cf-kv-utils.ts +1 -1
  140. package/src/cache/document-cache.ts +42 -15
  141. package/src/cache/handle-snapshot.ts +19 -7
  142. package/src/cache/segment-codec.ts +55 -0
  143. package/src/cache/shell-snapshot.ts +2 -2
  144. package/src/cache/tag-invalidation.ts +21 -2
  145. package/src/cache/taint.ts +5 -3
  146. package/src/cache/types.ts +7 -3
  147. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  148. package/src/handles/pushed-by.ts +1 -1
  149. package/src/prefetch-markers.ts +4 -7
  150. package/src/prerender/store.ts +76 -0
  151. package/src/prerender/warm-request.ts +8 -4
  152. package/src/prerender.ts +2 -2
  153. package/src/route-name.ts +8 -0
  154. package/src/router/error-handling.ts +4 -4
  155. package/src/router/instrument.ts +4 -3
  156. package/src/router/intercept-resolution.ts +77 -75
  157. package/src/router/loader-resolution.ts +5 -4
  158. package/src/router/match-api.ts +4 -5
  159. package/src/router/match-context.ts +33 -12
  160. package/src/router/match-handlers.ts +8 -20
  161. package/src/router/match-middleware/background-revalidation.ts +41 -53
  162. package/src/router/match-middleware/cache-lookup.ts +680 -720
  163. package/src/router/match-middleware/cache-store.ts +14 -12
  164. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  165. package/src/router/match-middleware/segment-resolution.ts +34 -61
  166. package/src/router/match-result.ts +1 -20
  167. package/src/router/middleware.ts +69 -11
  168. package/src/router/navigation-snapshot.ts +4 -3
  169. package/src/router/prerender-match.ts +100 -44
  170. package/src/router/preview-match.ts +6 -41
  171. package/src/router/request-classification.ts +13 -7
  172. package/src/router/revalidation.ts +33 -7
  173. package/src/router/route-snapshot.ts +2 -5
  174. package/src/router/router-context.ts +14 -117
  175. package/src/router/router-interfaces.ts +8 -14
  176. package/src/router/segment-resolution/loader-cache.ts +14 -9
  177. package/src/router/segment-resolution/loader-mask.ts +5 -5
  178. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  179. package/src/router/segment-resolution/resolution-pass.ts +137 -0
  180. package/src/router/segment-resolution/revalidation.ts +897 -776
  181. package/src/router/segment-resolution.ts +4 -13
  182. package/src/router/segment-wrappers.ts +51 -269
  183. package/src/router/telemetry.ts +5 -0
  184. package/src/router/types.ts +10 -0
  185. package/src/router.ts +12 -13
  186. package/src/rsc/full-payload.ts +6 -5
  187. package/src/rsc/handler-context.ts +8 -14
  188. package/src/rsc/handler.ts +126 -507
  189. package/src/rsc/json-route-result.ts +5 -9
  190. package/src/rsc/loader-fetch.ts +6 -5
  191. package/src/rsc/progressive-enhancement.ts +5 -0
  192. package/src/rsc/request-stages.ts +757 -0
  193. package/src/rsc/response-cache-serve.ts +10 -14
  194. package/src/rsc/response-route-handler.ts +13 -5
  195. package/src/rsc/rewrite-passes.ts +2 -1
  196. package/src/rsc/routine-plan.ts +1 -1
  197. package/src/rsc/rsc-rendering.ts +172 -349
  198. package/src/rsc/server-action.ts +87 -113
  199. package/src/rsc/shell-capture-constants.ts +3 -1
  200. package/src/rsc/shell-capture.ts +212 -169
  201. package/src/rsc/shell-serve.ts +121 -18
  202. package/src/rsc/ssr-setup.ts +5 -4
  203. package/src/segment-system.tsx +3 -3
  204. package/src/server/context.ts +22 -25
  205. package/src/server/handle-store.ts +55 -0
  206. package/src/server/render-pass.ts +291 -0
  207. package/src/server/request-context.ts +26 -115
  208. package/src/server/rewrite-trail.ts +6 -4
  209. package/src/ssr/index.tsx +1 -1
  210. package/src/ssr/ssr-root.tsx +1 -0
  211. package/src/testing/dispatch.ts +223 -710
  212. package/src/testing/e2e/page-helpers.ts +2 -2
  213. package/src/testing/serve-shell-request.ts +37 -32
  214. package/src/types/cache-types.ts +6 -3
  215. package/src/types/error-types.ts +9 -1
  216. package/src/types/handler-context.ts +21 -7
  217. package/src/types/segments.ts +1 -1
  218. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  219. package/src/vite/discovery/build-env.ts +148 -0
  220. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  221. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  222. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  223. package/src/vite/discovery/discover-routers.ts +1 -1
  224. package/src/vite/discovery/discovery-errors.ts +1 -1
  225. package/src/vite/discovery/prerender-collection.ts +24 -39
  226. package/src/vite/discovery/state.ts +15 -0
  227. package/src/vite/plugins/version-plugin.ts +1 -1
  228. package/src/vite/plugins/virtual-entries.ts +1 -1
  229. package/src/vite/router-discovery.ts +29 -2428
  230. package/src/vite/temp-rsc-server.ts +223 -0
  231. package/src/vite/utils/prerender-utils.ts +47 -0
  232. package/src/wire-params.ts +35 -0
  233. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  234. package/src/router/segment-resolution/fresh.ts +0 -945
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.26.6",
3
+ "version": "0.28.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -320,11 +320,14 @@ domains, and the router id keeps two routers on one store apart when both
320
320
  serve the same host and path (see `/host-router`, "Shared cache store"); the search part is sorted, excludes the router's internal params, and
321
321
  honors `createRouter({ cache: { searchParams } })`.
322
322
 
323
- A custom `key` function replaces the whole default key (e.g., to key by user role
324
- or locale); it also bypasses the store's `keyGenerator` and the search-param
325
- filter. It runs once per request and may read `cookies()`. Its result is
326
- stored namespaced (`key:` plus its URI encoding), so no value it returns, raw
327
- request input included, names another route's record. A nested `cache()`
323
+ A custom `key` function replaces the URL part of the default key (e.g., to
324
+ key by user role or locale), not the request type: document loads, client
325
+ navigations and intercept navigations still keep their own records, and the
326
+ key decides which requests of one type share a record. It also bypasses the
327
+ store's `keyGenerator` and the search-param filter. It runs once per request
328
+ and may read `cookies()`. Its result is stored namespaced (`key:` plus its URI
329
+ encoding, then the request type), so no value it returns, raw request input
330
+ included, names another route's record. A nested `cache()`
328
331
  keys its records within the enclosing `key()` partition (`/caching` → "Keys
329
332
  nest"). Normalize the result to the values you serve: each distinct value is
330
333
  a record. On a `ppr` route, `key()` (and a store `keyGenerator` that returns
@@ -102,7 +102,9 @@ tainted objects" below).
102
102
 
103
103
  Handle pushes follow the same split. Handler pushes (`ctx.use(Meta)`,
104
104
  `ctx.use(Breadcrumbs)`, custom handles) are stored with the segments and
105
- replayed on a hit. Pushes from a `loader()` body are not stored: the loader
105
+ replayed on a hit. On a navigation the hit replays them only for the segments
106
+ it sends: a layout the browser keeps keeps its values on screen, as on an
107
+ uncached navigation. Pushes from a `loader()` body are not stored: the loader
106
108
  re-runs on the hit and pushes again, so each value appears once. A loader that
107
109
  runs only because a cached handler awaited it with `ctx.use(Loader)` does not
108
110
  re-run on a hit, so its pushes are stored with the handler's.
@@ -154,7 +156,7 @@ cache(
154
156
  swr: 300, // seconds a stale entry is served while it refreshes.
155
157
  // Default: store.defaults.swr, else no SWR window
156
158
  tags: ["blog"], // or (ctx) => string[]; see "Tag-Based Invalidation"
157
- key: (ctx) => `blog:${ctx.pathname}`, // replaces the default key entirely
159
+ key: (ctx) => `blog:${ctx.pathname}`, // replaces the URL part of the default key
158
160
  condition: (ctx) => !ctx.request.headers.has("x-preview"), // false = bypass
159
161
  store: blogStore, // per-boundary store; see "Custom Cache Store"
160
162
  },
@@ -164,15 +166,26 @@ cache(
164
166
  );
165
167
  ```
166
168
 
167
- - **Default key**: `{doc|partial|intercept}:{routerId}@{host}{pathname}[:params][?search]`
168
- — document requests, client navigations, and intercept navigations are cached
169
- separately, and so are two routers that share a store (see `/host-router`,
170
- "Shared cache store"). The search part honors `cache.searchParams` (below).
171
- - **`key`** is a full override: it bypasses the default key, the store's
172
- `keyGenerator`, and the search-param filter. The result is stored
173
- namespaced (`key:` plus its URI encoding), so whatever it returns, request
174
- input included, never names another route's default-keyed record or a
175
- nested partition's. A nested `cache()` keys its records within that
169
+ - **Default key**: `{doc|partial|intercept:<intercept>}:{routerId}@{host}{pathname}[:params][?search]`
170
+ — under this key, document requests, client navigations, and intercept
171
+ navigations are cached separately, each intercept that targets the route
172
+ apart (`<intercept>` names the one the navigation selected), and so are two
173
+ routers that share a store (see `/host-router`, "Shared cache store"). The
174
+ search part honors `cache.searchParams` (below).
175
+ - **`key`** replaces the URL part of the default key (host, path, params,
176
+ search): it bypasses those, the store's `keyGenerator`, and the
177
+ search-param filter. The request kind stays: document requests, client
178
+ navigations and each intercept navigation keep their own record under one
179
+ `key()` result, so `key()` decides which requests of one kind share a
180
+ record (a `key()` that ignores `:id` shares one document record across
181
+ ids), never that a document request reads a navigation's record (which
182
+ keeps the fallback of a `loading(..., { ssr: false })` that a document
183
+ leaves out) or an intercept's. The
184
+ result is stored namespaced (`key:` plus its URI encoding, then the kind:
185
+ `tier:gold` is `key:tier%3Agold|doc` for a document request and
186
+ `key:tier%3Agold|partial` for a navigation), so whatever it returns,
187
+ request input included, never names another route's default-keyed record
188
+ or a nested partition's. A nested `cache()` keys its records within that
176
189
  partition (see "Keys nest" under "Nested Cache Boundaries"). On a `ppr`
177
190
  route the same key (or, without one, the store's `keyGenerator`)
178
191
  partitions the PPR shell: each partition captures and serves its own shell
@@ -532,7 +545,7 @@ const router = createRouter({
532
545
  baked-shell manifest gate (a URL whose only params are excluded ones now
533
546
  matches the prerendered shell), and `"use cache"` ctx key normalization.
534
547
  - **`cache({ key })` override wins**: a custom key bypasses the filter along
535
- with the rest of default key generation.
548
+ with the rest of the default key's URL part.
536
549
  - **The footgun**: excluding a param promises the rendered output does not
537
550
  depend on it. If it does, the first variant is cached and served to everyone.
538
551
  That is why the default is `"all"`.
@@ -725,7 +738,9 @@ default, shared by every router on the host with that prefix; `HttpOnly`,
725
738
  `SameSite=Lax`, `Path=/`, `Secure` on https, `Max-Age` 11 s with KV and 3 s
726
739
  without at the defaults: the longest memo staleness plus 1 s). It is strictly
727
740
  functional: its value is `1`, it identifies no one, and it only sends that
728
- browser's reads past the memos. The same user's requests that carry it skip
741
+ browser's reads past the memos. A stale entry's background refresh (`cache()`,
742
+ `"use cache"`, a cached loader, the document cache) sets none: no visitor made
743
+ that invalidation. The same user's requests that carry it skip
729
744
  both memos on every isolate, so a mutating user never sees a memoized shell or
730
745
  marker from before their write. What they read past the memos is the store's
731
746
  own consistency. `VercelCacheStore`: `expireTag` is global (about 300 ms), so
@@ -1084,7 +1099,8 @@ cache({ ttl: 300, key: (ctx) => `tier:${tier(ctx)}` }, () => [
1084
1099
  path("/faq", FaqPage, { name: "faq" }),
1085
1100
  ]),
1086
1101
 
1087
- // Its own key() composes with the outer one ("key:tier%3Agold|key:v%3Ab")
1102
+ // Its own key() composes with the outer one, then the request kind
1103
+ // ("key:tier%3Agold|key:v%3Ab|doc")
1088
1104
  cache(
1089
1105
  { ttl: 60, key: (ctx) => `v:${ctx.searchParams.get("v") ?? "a"}` },
1090
1106
  () => [path("/plans", PlansPage, { name: "plans" })],
@@ -1099,10 +1115,10 @@ results first, outermost first:
1099
1115
 
1100
1116
  | Boundary the route sits in | Record key |
1101
1117
  | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1102
- | a single keyed `cache()` | Its `key()` result, namespaced (`tier:gold` is stored as `key:tier%3Agold`) |
1118
+ | a single keyed `cache()` | Its `key()` result, namespaced, then the request kind (`tier:gold` is stored as `key:tier%3Agold\|doc` for a document request) |
1103
1119
  | `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 |
1104
- | `cache({ key })` under a keyed one | The chain's `key()` results |
1105
- | deeper nesting | Composes the same way; only the innermost boundary's missing `key` adds its default key |
1120
+ | `cache({ key })` under a keyed one | The chain's `key()` results, then the request kind |
1121
+ | deeper nesting | Composes the same way; the innermost boundary adds its default key without a `key` of its own, its request kind with one |
1106
1122
  | 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 |
1107
1123
  | `cache(false)` | No caching, as before; a `cache()` re-enabled below it stays in the partition |
1108
1124
  | no `key` anywhere | The default key, or the store's `keyGenerator` result, as before |
@@ -1134,7 +1150,10 @@ results first, outermost first:
1134
1150
  empty result there fails key resolution, so the request renders uncached,
1135
1151
  and the router warns once naming the store. On the same store the inner
1136
1152
  boundary's own default key already carries it (or its own `key()`
1137
- overrides it).
1153
+ overrides it). Keep the default key in the result
1154
+ (`` `${locale}:${defaultKey}` ``): its prefix is the request kind, and a
1155
+ result without it gives a document request, a navigation and each
1156
+ intercept navigation one record.
1138
1157
  - A response route's entry (`path.json()` and the other response routes) is
1139
1158
  keyed the same way, behind a `response:` prefix. A `ppr` route's shell is
1140
1159
  partitioned by the chain's `key()` results, namespaced the same way, since
@@ -326,7 +326,8 @@ Per request: a JS action POST runs the predicate with `method: "POST"`,
326
326
  `FormData` argument) and `actionUrl`; the default is true. An in-group
327
327
  navigation GET defaults to true when params or search changed. A prefetch
328
328
  partial runs the predicate, so a prefetched in-group navigation honours it. A
329
- stale back/forward revalidation sees `stale: true`. Document requests and
329
+ stale back/forward revalidation sees `stale: true` and takes an action's
330
+ defaults (the default is true). Document requests and
330
331
  no-JS form posts hold no segments, so every loader renders whatever a predicate
331
332
  says. On the action-triggered refetch GET (consolidation) `isAction()` is false,
332
333
  as for `urls()`. `currentParams`/`nextParams` are the server's route params,
@@ -161,6 +161,16 @@ short-circuits the middleware's downstream pipeline; global middleware that
161
161
  wraps it can still run. Route middleware, handlers, and loaders below it do not.
162
162
  An external CDN hit is different: the function never runs at all.
163
163
 
164
+ The background revalidation re-runs that downstream pipeline for the stale
165
+ entry. Its cookies, and the headers its route handlers and the middleware
166
+ after the cache write, go on its own response, never on the visitor's `STALE`
167
+ response; middleware ahead of the cache gets the `STALE` response from
168
+ `next()`, and its writes after `next()` land there. It
169
+ starts from what middleware ahead of the cache wrote to the response before
170
+ `next()`, as a `MISS` does, and its response is stored by the rules below:
171
+ its headers are kept, and a cookie it sets keeps the refreshed page out of the
172
+ cache.
173
+
164
174
  ## Cache Status Header
165
175
 
166
176
  Response includes `x-document-cache-status`:
@@ -202,6 +212,11 @@ suffixes are still appended.
202
212
 
203
213
  - Server actions (`_rsc_action`)
204
214
  - Loader requests (`_rsc_loader`)
215
+ - Stale requests (`_rsc_stale`): the client marks stale the follow-up
216
+ revalidation after an action, a Back/Forward entry's revalidation, and any
217
+ request that holds no segments (`router.refresh()`, a cross-tab refresh).
218
+ Each renders at the origin and is not stored, because its data may predate
219
+ a write and the cache cannot tell.
205
220
  - Non-GET requests
206
221
  - Responses without an `s-maxage` `Cache-Control` directive
207
222
  - Responses whose `Cache-Control` also contains `private`, `no-store`, or an
@@ -210,7 +225,9 @@ suffixes are still appended.
210
225
  everyone). That includes a response whose request called
211
226
  `updateTag()`/`revalidateTag()` on a store that keeps per-isolate memos
212
227
  (`CFCacheStore`, `VercelCacheStore`): it carries the fresh-reads cookie
213
- (`rango-state-fresh` by default), so it is not stored.
228
+ (`rango-state-fresh` by default), so it is not stored. A stale entry's
229
+ background re-run sets no fresh-reads cookie (no visitor made that
230
+ invalidation), so its invalidation does not keep the refreshed page out.
214
231
  - Non-200 responses
215
232
  - A 200 whose render reported an error: a component that threw after the
216
233
  response started streaming, such as an async server component whose fetch
@@ -368,7 +368,7 @@ Per-slot merge order is **handler.use → shared use → slot-local use**. Slot-
368
368
 
369
369
  Other things to keep in mind about `loading()`:
370
370
 
371
- - Any `loading()` (regardless of source) makes the segment a streaming unit. A handler that includes `loading()` in its `.use` opts every mount site into streaming by default. To opt back out, pass `loading(false)` at the mount site (`loading === false` handling in [segment-resolution/fresh.ts](../../src/router/segment-resolution/fresh.ts)) — use the slot descriptor form for parallel slots so the opt-out doesn't broadcast.
371
+ - Any `loading()` (regardless of source) makes the segment a streaming unit. A handler that includes `loading()` in its `.use` opts every mount site into streaming by default. To opt back out, pass `loading(false)` at the mount site (`loading === false` handling in [segment-resolution/revalidation.ts](../../src/router/segment-resolution/revalidation.ts)) — use the slot descriptor form for parallel slots so the opt-out doesn't broadcast.
372
372
 
373
373
  Rule of thumb: only put `loading()` in `handler.use` if you genuinely want every mount site to stream by default. Use the slot descriptor's `use` for any per-slot intent at a `parallel()` call.
374
374
 
@@ -64,11 +64,29 @@ export const urlpatterns = urls(({ path, layout, intercept, loader, loading }) =
64
64
 
65
65
  ## Navigation Behavior
66
66
 
67
- | Navigation Type | What Renders |
68
- | ------------------------------ | ---------------------------------------------------- |
69
- | Click link `/shop/product/abc` | `<ProductModal />` in `@modal`, background preserved |
70
- | Direct URL `/shop/product/abc` | Full `<ProductPage />` page |
71
- | Browser back | Close modal, restore previous state |
67
+ | Navigation Type | What Renders |
68
+ | ----------------------------------------------------- | ---------------------------------------------------- |
69
+ | Click link `/shop/product/abc` | `<ProductModal />` in `@modal`, background preserved |
70
+ | Direct URL `/shop/product/abc`, or a reload | Full `<ProductPage />` page |
71
+ | Back from the open intercept | The intercept closes; the source page stays |
72
+ | Back/Forward onto the intercept's entry | The intercept over its source page |
73
+ | The same, uncached, from a page other than the source | Full `<ProductPage />` page, as a document load |
74
+
75
+ Only a client navigation from the source page shows an intercept. Back/Forward
76
+ restores an intercept's entry from the history cache (`initBrowserApp`'s
77
+ `storeOptions.cacheSize`, 20 by default), or fetches it when its source page is
78
+ on screen. From any other page (after a reload, or once the entry was evicted)
79
+ it costs a full page load, from the top, which drops `clearOnReload` location
80
+ state like any document load, and the entry is a full page from then on. In
81
+ dev, an edit that changes the app version clears the history cache, so the
82
+ next Back/Forward onto an intercept entry takes that path.
83
+
84
+ A server action on `/shop` that calls `redirect("/shop/product/abc")` opens the
85
+ intercept too, and so does a link from `/shop` to a route that redirects
86
+ there. The page under it is the browser's copy, as on a link click: it
87
+ keeps its last render, so it shows its pre-action data until the intercept
88
+ closes. An action submitted from inside an open intercept leaves that page the
89
+ same way.
72
90
 
73
91
  ## Intercept with Layout
74
92
 
@@ -146,6 +164,18 @@ the modal keeps the data of the others, as it does on a link to another
146
164
  intercepted route that reads the same loader. In `clientUrls()` the predicate
147
165
  runs on the server too (`/client-urls`).
148
166
 
167
+ The page under an intercept does not re-render while the intercept is on
168
+ screen. An action that settles after the intercept opened (one sent from the
169
+ page underneath just before the user opened it) runs no background
170
+ revalidation. The page underneath keeps its data until the user leaves the
171
+ intercept. Leaving it with Back revalidates that page as stale (the
172
+ action marked the history cache stale): loaders that opt in with
173
+ `revalidate(({ stale }) => stale)` re-run. Going Back or Forward out of the
174
+ intercept while an action from it is in flight does not abort the action:
175
+ `useAction()` gets its result, and the page underneath gets the background
176
+ revalidation (`/server-actions`, "When the user navigates while the action
177
+ runs").
178
+
149
179
  A `revalidate()` placed directly in the intercept's `use()` (or returned from
150
180
  its handler's `.use`) throws at definition time. Attach it to the loader:
151
181
 
@@ -177,7 +207,7 @@ import type { Revalidate } from "@rangojs/router";
177
207
  import * as ProductActions from "./actions/product";
178
208
 
179
209
  export const revalidateProductShell: Revalidate = (ctx) =>
180
- ctx.isAction(ProductActions) || undefined;
210
+ ctx.stale || ctx.isAction(ProductActions) || undefined;
181
211
 
182
212
  layout(ProductLayout, () => [
183
213
  revalidate(revalidateProductShell), // producer reruns
@@ -340,9 +370,14 @@ below), and `cache()` onto the target route.
340
370
 
341
371
  To cache a modal, put `cache()` on the target route (or a layout above it).
342
372
  An intercept navigation is cached under that scope with its own `intercept:`
343
- key, so it never collides with the full-page render of the same URL. For
344
- finer control, use `"use cache"` in the intercept handler, or cache the
345
- intercept's loader: `loader(ProductLoader, () => [cache()])`.
373
+ key, so it never collides with the full-page render of the same URL. The key
374
+ names the intercept the navigation selected, so two intercepts that target one
375
+ route with different `when()` conditions each keep their own record. Both
376
+ hold under a `cache({ key })` too: the `key()` replaces the URL part of the
377
+ key, never the request kind, so a document request and a plain navigation
378
+ never read an intercept's record (see `/caching`). For finer control, use
379
+ `"use cache"` in the intercept handler, or cache the intercept's loader:
380
+ `loader(ProductLoader, () => [cache()])`.
346
381
 
347
382
  ## Errors in an Intercept
348
383
 
@@ -433,7 +468,9 @@ contract and direction-aware examples.
433
468
  ## Interaction with Prerender
434
469
 
435
470
  When the target route of an intercept uses `Prerender`, the intercept handler is
436
- also resolved at build time and stored alongside the main pre-rendered segments.
471
+ also resolved at build time and stored in an intercept variant next to the
472
+ route's own pre-rendered page. The variant holds what an intercept navigation
473
+ renders: the layouts above the route and the intercept, not the route's page.
437
474
  This means intercept navigations to pre-rendered routes are served from the
438
475
  prerender store without executing handler code at runtime.
439
476
 
@@ -466,13 +503,16 @@ Build-time behavior:
466
503
  - The intercept handler (`<ProductModal />`) is resolved with BuildContext
467
504
  - Result is stored under the key `"detail/paramHash/i"` (intercept variant)
468
505
  - `when` config conditions are skipped at build time (all intercepts pre-rendered unconditionally)
469
- - `when` is still evaluated at runtime by the intercept-resolution middleware
506
+ - `when` is still evaluated at runtime by the intercept-resolution middleware,
507
+ and a navigation shows only the intercept it selected
508
+ - Two intercepts on one layout that fill the same slot are not pre-rendered:
509
+ they render live
470
510
 
471
511
  Runtime behavior:
472
512
 
473
- - Intercept navigation: prerender store serves the `/i` variant (frozen handler + fresh loaders)
513
+ - Intercept navigation: prerender store serves the `/i` variant (frozen handler + fresh loaders). A layout the client holds (`ShopLayout`) stays as it is on screen, as on a live intercept, unless its `revalidate()` asks for it
474
514
  - Direct navigation: prerender store serves the main variant (full page)
475
- - If no intercept prerender entry exists, falls through to live intercept resolution
515
+ - If no intercept prerender entry exists, or it lacks the selected intercept, falls through to live intercept resolution
476
516
 
477
517
  Loaders inside the intercept always run fresh at request time, same as regular
478
518
  pre-rendered routes.
@@ -238,7 +238,8 @@ A standalone `layout()` renders when it is first mounted and is then kept as-is:
238
238
  params auto-filled) keep the old params too; render param-dependent nav in a
239
239
  client component with `useReverse(routes)` (`/links`).
240
240
  - **Actions** skip it too (the revalidation trace calls this
241
- `action:parent-chain-skip`).
241
+ `action:parent-chain-skip`), and so does Back/Forward onto a page an action
242
+ made stale (`stale:parent-chain-skip`).
242
243
 
243
244
  Orphan layouts inside a `path()` are the opposite: they belong to the route
244
245
  entry and re-render with it (on param/search changes and on every action).
@@ -249,9 +250,9 @@ or `undefined` to defer to the default:
249
250
  ```typescript
250
251
  import * as CartActions from "./actions/cart";
251
252
 
252
- // Re-render after any cart action
253
+ // Re-render after any cart action, and on Back after an action
253
254
  layout(<CartLayout />, () => [
254
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
255
+ revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
255
256
 
256
257
  path("/cart", CartPage, { name: "cart" }),
257
258
  ])
@@ -292,7 +293,7 @@ import type { Revalidate } from "@rangojs/router";
292
293
  import { addToCart } from "./actions/cart";
293
294
 
294
295
  export const revalidateCartData: Revalidate = (ctx) =>
295
- ctx.isAction(addToCart) || undefined;
296
+ ctx.stale || ctx.isAction(addToCart) || undefined;
296
297
  ```
297
298
 
298
299
  ```typescript
@@ -305,8 +306,11 @@ layout(<CartLayout />, () => [
305
306
  ```
306
307
 
307
308
  On the layout the contract adds the re-render the default skips after an
308
- action. The route segment already re-runs after every action, so there it
309
- changes nothing by default; the shared name documents the dependency.
309
+ action, and on Back/Forward onto a page an action made stale (`ctx.stale`).
310
+ The route segment already re-runs on both, so there it changes nothing by
311
+ default; the shared name documents the dependency. Leave out `ctx.stale` and
312
+ Back after any action re-runs the page without its layout: the page reads
313
+ `undefined`.
310
314
 
311
315
  If a segment depends on multiple upstream domains, compose multiple
312
316
  contracts (`revalidateAuthData`, `revalidateCartData`, and so on).
@@ -319,7 +323,7 @@ import { revalidate, type Revalidate } from "@rangojs/router";
319
323
  import * as AuthActions from "./actions/auth";
320
324
 
321
325
  export const revalidateAuthData: Revalidate = (ctx) =>
322
- ctx.isAction(AuthActions) || undefined;
326
+ ctx.stale || ctx.isAction(AuthActions) || undefined;
323
327
  export const revalidateAuth = () => [revalidate(revalidateAuthData)];
324
328
  ```
325
329
 
@@ -396,12 +396,26 @@ Segment defaults when no revalidator decides (from
396
396
  `src/router/revalidation.ts`). A segment the client does not hold yet always
397
397
  renders.
398
398
 
399
- | Segment | After an action (POST) | Navigation |
400
- | -------------------------------------------------------------------- | ---------------------- | ----------------------------------- |
401
- | Route (`path()`) | `true` | `true` when params or search change |
402
- | Owned by the route (loaders, layouts, parallels inside the `path()`) | `true` | `true` when params or search change |
403
- | Loader above the route (e.g. on a parent `layout()`) | `true` | `false` |
404
- | Layout or parallel above the route | `false` | `false` |
399
+ | Segment | After an action (POST), or a stale Back/Forward | Navigation |
400
+ | -------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------- |
401
+ | Route (`path()`) | `true` | `true` when params or search change |
402
+ | Owned by the route (loaders, layouts, parallels inside the `path()`) | `true` | `true` when params or search change |
403
+ | Loader above the route (e.g. on a parent `layout()`) | `true` | `false` |
404
+ | Layout or parallel above the route | `false` | `false` |
405
+
406
+ A stale Back/Forward is the background revalidation of a page a server action
407
+ (in this tab or another) marked stale: Back renders the cached page, then
408
+ revalidates it with `stale: true`. It takes an action's defaults, so a layout's
409
+ cart count shows the write with no opt-in. It renders live, as an action does:
410
+ no route-cache record or PPR replay answers it. It is not an action:
411
+ `isAction()` is false and `actionId` is undefined. To keep a loader as it was
412
+ on that request:
413
+
414
+ ```typescript
415
+ loader(RecommendationsLoader, () => [
416
+ revalidate(({ stale }) => (stale ? false : undefined)),
417
+ ]);
418
+ ```
405
419
 
406
420
  #### `|| undefined` (defer) vs `?? false` (hard) — pick deliberately
407
421
 
@@ -421,9 +435,10 @@ Pick the idiom by what the segment's default already does (table above). After
421
435
  an action, loaders, the route, and segments inside the `path()` already re-run;
422
436
  layouts and parallels above the route are skipped:
423
437
 
424
- - **Add a signal where the default skips.** `ctx.isAction(X) || undefined` on a
425
- parent layout or a layout-level parallel re-renders it after `X` and defers
426
- otherwise. On a loader, the route, or a segment inside the `path()` it
438
+ - **Add a signal where the default skips.**
439
+ `ctx.isAction(X) || ctx.stale || undefined` on a parent layout or a
440
+ layout-level parallel re-renders it after `X` and on Back after an action,
441
+ and defers otherwise. On a loader, the route, or a segment inside the `path()` it
427
442
  changes nothing: they already re-run after every action, and on navigation it
428
443
  defers.
429
444
  - **Narrow after actions where the default re-runs.**
@@ -487,10 +502,12 @@ the same named revalidation contract across producer and consumer segments.
487
502
  import type { Revalidate } from "@rangojs/router";
488
503
  import * as AccountActions from "./actions/account";
489
504
 
490
- // Match by reference with ctx.isAction() (rename-safe), and defer (|| undefined)
491
- // so these contracts compose — a hard `false` would short-circuit the rest.
505
+ // Match by reference with ctx.isAction() (rename-safe), true on a stale
506
+ // Back/Forward (the page re-runs, so its producer must), and defer
507
+ // (|| undefined) so these contracts compose — a hard `false` would
508
+ // short-circuit the rest.
492
509
  export const revalidateAccountScope: Revalidate = (ctx) =>
493
- ctx.isAction(AccountActions) || undefined;
510
+ ctx.stale || ctx.isAction(AccountActions) || undefined;
494
511
 
495
512
  // urls.tsx
496
513
  layout(AccountLayout, () => [
@@ -955,7 +972,7 @@ function ProductPage() {
955
972
 
956
973
  A skeleton that has appeared stays on screen for at least about 300 ms before React replaces it with what it was waiting for: React does not replace a fallback sooner than 300 ms after the last fallback committed ([#1078](https://github.com/rangojs/rango/issues/1078)). On a cold click that mounts the `loading()` boundary, a loader that finishes sooner than that still shows its skeleton for about 300 ms, while a slower loader is not delayed. The router leaves this to React: an early skeleton is preferred to holding the click, and a fully prefetched click commits complete and shows none. A nested `loading()` whose data lands after the outer one has been revealed shows its own fallback, and the 300 ms starts again. The loaders feature page on the docs site has the measured figures. Under `transition()` a related cost applies; see [view-transitions](../view-transitions/SKILL.md).
957
974
 
958
- A loader that throws (anything but `notFound()` or `redirect()`) throws at its `useLoader` read on every client lane: a plain or fully prefetched click, Back/Forward restored from the history cache, and the rerun after a server action. A prefetch in which a loader threw is never used, in memory or from the browser's HTTP cache: the click asks the server again, so a loader that has recovered shows its data. The nearest boundary to the read catches it (your React error boundary around the read, else the route's `errorBoundary()`, else the router's root one), and the layouts above keep their instance. The background refresh after Back/Forward onto a stale page is the exception: a loader that fails there leaves the page on screen and logs `[Browser] Background revalidation failed`. Not covered: an `intercept()`'s loaders, and a parallel slot's loaders under its own `loading()`, which are decoded above the slot's content.
975
+ A loader that throws (anything but `notFound()` or `redirect()`) throws at its `useLoader` read on every client lane: a plain or fully prefetched click, Back/Forward restored from the history cache, and the rerun after a server action. A prefetch in which a loader threw is never used, in memory or from the browser's HTTP cache: the click asks the server again, so a loader that has recovered shows its data. The nearest boundary to the read catches it (your React error boundary around the read, else the route's `errorBoundary()`, else the router's root one), and the layouts above keep their instance. The background refresh after Back/Forward onto a stale page is the exception: it re-runs every loader on the defaults (see the defaults table), and a loader that fails there leaves the page on screen as it was and logs `[Browser] Background revalidation failed`. Not covered: an `intercept()`'s loaders, and a parallel slot's loaders under its own `loading()`, which are decoded above the slot's content.
959
976
 
960
977
  ## Loader Authority: notFound() and redirect()
961
978
 
@@ -90,7 +90,7 @@ import type { Revalidate } from "@rangojs/router";
90
90
  import * as CartActions from "./actions/cart";
91
91
 
92
92
  export const revalidateCartData: Revalidate = (ctx) =>
93
- ctx.isAction(CartActions) || undefined;
93
+ ctx.stale || ctx.isAction(CartActions) || undefined;
94
94
 
95
95
  layout(CartLayout, () => [
96
96
  middleware(cartRenderMiddleware),
@@ -319,6 +319,24 @@ export const timing: Middleware = async (ctx, next) => {
319
319
  };
320
320
  ```
321
321
 
322
+ A middleware may answer without waiting for its `next()`, as a timeout
323
+ fallback does. The middleware ahead then see its `Response` after `next()`
324
+ and write on it. The abandoned render keeps running and builds its own
325
+ response, which nothing serves.
326
+
327
+ ```typescript
328
+ export const renderTimeout: Middleware = (ctx, next) =>
329
+ Promise.race([
330
+ next(),
331
+ new Promise<Response>((resolve) =>
332
+ setTimeout(
333
+ () => resolve(new Response("Try again", { status: 503 })),
334
+ 2000,
335
+ ),
336
+ ),
337
+ ]);
338
+ ```
339
+
322
340
  ### Typed context variables in middleware
323
341
 
324
342
  Use `createVar<T>()` for type-safe data sharing between middleware and handlers:
@@ -633,10 +633,11 @@ re-rendering:
633
633
  ```typescript
634
634
  import { updateBlog } from "./actions/blog";
635
635
 
636
- // Re-run this layout when a blog action fires (layouts above the route are
637
- // skipped after actions by default; undefined defers otherwise)
636
+ // Re-run this layout when a blog action fires, and on Back after an action
637
+ // (layouts above the route are skipped after actions by default; undefined
638
+ // defers otherwise)
638
639
  layout(BlogLayout, () => [
639
- revalidate((ctx) => ctx.isAction(updateBlog) || undefined),
640
+ revalidate((ctx) => ctx.isAction(updateBlog) || ctx.stale || undefined),
640
641
  path("/blog/:slug", BlogPost, { name: "blogPost" }),
641
642
  ]);
642
643
 
@@ -860,7 +861,7 @@ Server actions work the same way — `"use server"` directive, `useActionState`,
860
861
 
861
862
  Key difference: in Rango, route middleware does NOT wrap action execution. Actions only see global middleware context. Use `getRequestContext()` in actions to access `ctx.set()`/`ctx.get()`.
862
863
 
863
- Next.js's `revalidateTag()` maps directly: tag entries via `cache({ tags })` / `cacheTag(...)`, then invalidate. **In a server action use `await updateTag(tag)`** — it is read-your-own-writes, so the action's own re-render sees fresh data, and it rejects when the store's durable write fails; `revalidateTag(tag)` is a background (non-blocking) hard-purge for route handlers / webhooks. The request that calls `revalidateTag()` also reads its own writes, but a failed durable write only reaches `onError`. `revalidatePath()` has no path-based equivalent — tag the route's entries instead. Separately, the route, the segments inside its `path()`, and all loaders already re-run after every action; layouts and parallels above the route are skipped. To re-render one of those skipped segments after an action, attach `revalidate((ctx) => ctx.isAction(updateBlog) || undefined)` to it; to narrow a loader or route segment to one action, use `revalidate((ctx) => (ctx.isAction() ? ctx.isAction(updateBlog) : undefined))` (match the imported action by reference, as in §3). See `/server-actions` for the full pattern (validation, error handling, file uploads), `/caching` for tag invalidation, and `/loader` for revalidation rule semantics.
864
+ Next.js's `revalidateTag()` maps directly: tag entries via `cache({ tags })` / `cacheTag(...)`, then invalidate. **In a server action use `await updateTag(tag)`** — it is read-your-own-writes, so the action's own re-render sees fresh data, and it rejects when the store's durable write fails; `revalidateTag(tag)` is a background (non-blocking) hard-purge for route handlers / webhooks. The request that calls `revalidateTag()` also reads its own writes, but a failed durable write only reaches `onError`. `revalidatePath()` has no path-based equivalent — tag the route's entries instead. Separately, the route, the segments inside its `path()`, and all loaders already re-run after every action; layouts and parallels above the route are skipped. To re-render one of those skipped segments after an action, and on Back after one, attach `revalidate((ctx) => ctx.isAction(updateBlog) || ctx.stale || undefined)` to it; to narrow a loader or route segment to one action, use `revalidate((ctx) => (ctx.isAction() ? ctx.isAction(updateBlog) : undefined))` (match the imported action by reference, as in §3). See `/server-actions` for the full pattern (validation, error handling, file uploads), `/caching` for tag invalidation, and `/loader` for revalidation rule semantics.
864
865
 
865
866
  ## 8. Metadata / Head
866
867
 
@@ -392,8 +392,9 @@ parallel(
392
392
  () => [
393
393
  loader(CartLoader),
394
394
  // Layout-level slot (kept after actions by default): also re-render after
395
- // cart actions. Under a path() the slot re-renders after every action anyway.
396
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
395
+ // cart actions and on Back after an action. Under a path() the slot
396
+ // re-renders after every action anyway.
397
+ revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
397
398
  ]
398
399
  )
399
400
  ```
@@ -456,7 +457,7 @@ import type { Revalidate } from "@rangojs/router";
456
457
  import * as CartActions from "./actions/cart";
457
458
 
458
459
  export const revalidateCartData: Revalidate = (ctx) =>
459
- ctx.isAction(CartActions) || undefined;
460
+ ctx.stale || ctx.isAction(CartActions) || undefined;
460
461
 
461
462
  layout(CartLayout, () => [
462
463
  revalidate(revalidateCartData), // producer reruns
@@ -575,7 +576,7 @@ export const shopPatterns = urls(({
575
576
  () => [
576
577
  loader(CartLoader),
577
578
  loading(<CartSkeleton />),
578
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
579
+ revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
579
580
  ]
580
581
  ),
581
582
 
@@ -450,7 +450,7 @@ non-claiming read option.
450
450
 
451
451
  The first cold partial request reports `BYPASS; reason=no-entry` because no
452
452
  artifact could supply that response; its successful render schedules
453
- `scheduleShellCapture` with a navigation-only marker. The next request reports
453
+ `planShellCapture` with a navigation-only marker. The next request reports
454
454
  `HIT` when the capture produced an eligible snapshot. Navigation snapshots use
455
455
  a separate shell key, so they cannot overwrite or be served as a document shell.
456
456
  The capture runs with the stripped target document URL and loads SSR support in
@@ -482,6 +482,7 @@ The bounded bypass tokens, grouped by when they are decided:
482
482
  | Token | Decided | Meaning |
483
483
  | ----------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
484
484
  | `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
485
+ | `stale` | pre-read | a stale request (`_rsc_stale`, Back/Forward onto a page an action made stale) renders live, as an action does: the record may predate the write |
485
486
  | `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path` and no same-origin `Referer`; a partial match is impossible |
486
487
  | `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant) |
487
488
  | `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
@@ -508,7 +509,9 @@ In handlers it is
508
509
  too late to prevent a MISS render from already happening, but it still prevents
509
510
  the follow-up shell capture. A `ctx.dynamic()` call that only happens during the
510
511
  capture itself (for example inside a handler promise, after an await) refuses
511
- that capture: a dynamic render has no shell.
512
+ that capture: a dynamic render has no shell. A cached handler that calls it in
513
+ the background refresh of a stale `cache()` record does not: the capture
514
+ replayed the record, and the refresh renders the record only.
512
515
 
513
516
  During `Prerender` + `ppr` build-shell capture, middleware is replayed with
514
517
  `ctx.build === true`, `ctx.waitUntil()` inert, and the same `ctx.dynamic()`
@@ -1176,7 +1179,7 @@ their data should bake vs stay live. Your levers, in order of preference:
1176
1179
  3. **Guaranteed-fresh widgets: live loaders with a widget-sized boundary.**
1177
1180
  An unflagged loader read under an inline `<Suspense>` in the widget is
1178
1181
  enough. Alternatively, a parallel slot with its OWN `loading()`:
1179
- parallel-owned loaders get their own per-slot boundary (`fresh.ts` tags
1182
+ parallel-owned loaders get their own per-slot boundary (`resolveParallelSegments` tags
1180
1183
  them with the slot's loading; `segment-system.tsx` builds a per-slot
1181
1184
  LoaderBoundary), so the chrome bakes into the shell and each widget is an
1182
1185
  independent, widget-sized hole:
@@ -1632,6 +1635,19 @@ evicted by tag at all — move always-fresh data into a live-lane loader (no
1632
1635
  capture is served as before. Only a throw during the capture counts: a
1633
1636
  component that failed in the live render and succeeds when captured is
1634
1637
  stored.
1638
+ - **A handler that throws during capture refuses it too**: when a route,
1639
+ layout or parallel slot handler throws (or throws `notFound()`) on the
1640
+ capture's own run while the request that scheduled it rendered fine, for
1641
+ example on a flaky upstream, its boundary (a declared `errorBoundary()` or
1642
+ `notFoundBoundary()`, or the default error page) would become the shell.
1643
+ The capture stores nothing, logs `[ShellCache] capture`, reports to
1644
+ `onError` as `cache-write`, and backs the key off; the next capture after
1645
+ the window stores normally. A request whose own render fails answers
1646
+ 500/404 and schedules no capture, but a recapture has no visitor render
1647
+ behind it: a stale shell whose recapture fails keeps serving until its
1648
+ `ttl` + `swr` ends (a build-time shell, until a recapture stores), as a
1649
+ failed `cache()` refresh does, logging and calling `onError` once per
1650
+ backoff window. When you delete a page, invalidate its tag.
1635
1651
 
1636
1652
  ## Related
1637
1653