@rangojs/router 0.19.1 → 0.21.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 (185) hide show
  1. package/dist/bin/rango.js +26 -14
  2. package/dist/types/browser/event-controller.d.ts +32 -0
  3. package/dist/types/browser/history-state.d.ts +16 -4
  4. package/dist/types/browser/navigation-transaction.d.ts +7 -0
  5. package/dist/types/browser/rango-state.d.ts +3 -1
  6. package/dist/types/browser/react/context.d.ts +11 -0
  7. package/dist/types/browser/react/location-state-shared.d.ts +84 -10
  8. package/dist/types/browser/react/location-state.d.ts +7 -2
  9. package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
  10. package/dist/types/cache/cache-scope.d.ts +4 -5
  11. package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
  12. package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
  13. package/dist/types/cache/handle-snapshot.d.ts +36 -50
  14. package/dist/types/cache/index.d.ts +2 -0
  15. package/dist/types/cache/shell-snapshot.d.ts +19 -3
  16. package/dist/types/cache/types.d.ts +21 -10
  17. package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
  18. package/dist/types/client-urls/navigation.d.ts +7 -1
  19. package/dist/types/client-urls/optimistic-location.d.ts +10 -4
  20. package/dist/types/handles/deferred-resolution.d.ts +11 -1
  21. package/dist/types/prerender/build-shell-capture.d.ts +8 -6
  22. package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
  23. package/dist/types/router/navigation-snapshot.d.ts +8 -1
  24. package/dist/types/router/router-interfaces.d.ts +2 -1
  25. package/dist/types/router/router-options.d.ts +17 -7
  26. package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
  27. package/dist/types/router-versions.d.ts +39 -0
  28. package/dist/types/rsc/handler-context.d.ts +5 -0
  29. package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
  30. package/dist/types/rsc/types.d.ts +4 -6
  31. package/dist/types/server/build-version-table.d.ts +44 -0
  32. package/dist/types/server/handle-store.d.ts +87 -46
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/ssr/entry-preload-priority.d.ts +69 -0
  35. package/dist/types/ssr/index.d.ts +1 -0
  36. package/dist/types/ssr/preinit-client-references.d.ts +17 -5
  37. package/dist/types/testing/build-versions.d.ts +32 -0
  38. package/dist/types/testing/dom.entry.d.ts +1 -1
  39. package/dist/types/testing/flight.entry.d.ts +1 -1
  40. package/dist/types/testing/index.d.ts +2 -0
  41. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  42. package/dist/types/testing/location-state-key.d.ts +5 -1
  43. package/dist/types/testing/render-route.d.ts +77 -6
  44. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  45. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  46. package/dist/types/types/handler-context.d.ts +8 -5
  47. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  48. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  49. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  50. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  51. package/dist/types/vite/discovery/state.d.ts +14 -0
  52. package/dist/types/vite/encryption-key.d.ts +18 -0
  53. package/dist/types/vite/plugin-types.d.ts +47 -6
  54. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  55. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  56. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  57. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  58. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  59. package/dist/types/vite/plugins/virtual-entries.d.ts +21 -2
  60. package/dist/types/vite/router-discovery.d.ts +6 -0
  61. package/dist/types/vite/utils/client-chunks.d.ts +24 -2
  62. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  63. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  64. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  65. package/dist/vite/index.js +1544 -420
  66. package/package.json +1 -2
  67. package/skills/bundle-analysis/SKILL.md +1 -1
  68. package/skills/cache-guide/SKILL.md +84 -20
  69. package/skills/caching/SKILL.md +16 -5
  70. package/skills/client-urls/SKILL.md +16 -0
  71. package/skills/cloudflare/SKILL.md +2 -1
  72. package/skills/comparison/references/framework-comparison.md +16 -12
  73. package/skills/hooks/SKILL.md +3 -0
  74. package/skills/hooks/handle-and-actions.md +6 -0
  75. package/skills/hooks/navigation.md +28 -0
  76. package/skills/hooks/state.md +209 -8
  77. package/skills/hooks/urls.md +40 -1
  78. package/skills/host-router/SKILL.md +13 -0
  79. package/skills/intercept/SKILL.md +6 -0
  80. package/skills/loader/SKILL.md +19 -1
  81. package/skills/middleware/SKILL.md +2 -0
  82. package/skills/ppr/SKILL.md +119 -40
  83. package/skills/prerender/SKILL.md +7 -3
  84. package/skills/rango/SKILL.md +8 -7
  85. package/skills/router-setup/SKILL.md +23 -3
  86. package/skills/testing/SKILL.md +30 -26
  87. package/skills/testing/client-components.md +193 -8
  88. package/skills/testing/handles.md +2 -0
  89. package/skills/testing/loader.md +1 -1
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/testing/render-handler.md +18 -18
  92. package/skills/testing/serve-shell-request.md +63 -14
  93. package/skills/testing/server-actions.md +17 -17
  94. package/skills/testing/server-tree.md +1 -1
  95. package/skills/typesafety/generated-files-and-cli.md +1 -1
  96. package/skills/use-cache/SKILL.md +16 -5
  97. package/skills/vercel/SKILL.md +3 -3
  98. package/src/bin/rango.ts +1 -1
  99. package/src/browser/event-controller.ts +75 -2
  100. package/src/browser/history-state.ts +39 -17
  101. package/src/browser/navigation-bridge.ts +41 -18
  102. package/src/browser/navigation-transaction.ts +15 -10
  103. package/src/browser/partial-update.ts +10 -2
  104. package/src/browser/rango-state.ts +3 -1
  105. package/src/browser/react/Link.tsx +2 -3
  106. package/src/browser/react/NavigationProvider.tsx +25 -1
  107. package/src/browser/react/context.ts +13 -0
  108. package/src/browser/react/location-state-shared.ts +200 -34
  109. package/src/browser/react/location-state.ts +40 -75
  110. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  111. package/src/browser/react/use-handle.ts +20 -1
  112. package/src/browser/rsc-router.tsx +33 -11
  113. package/src/browser/server-action-bridge.ts +17 -1
  114. package/src/cache/cache-scope.ts +18 -8
  115. package/src/cache/cf/cf-cache-store.ts +75 -55
  116. package/src/cache/cf/cf-cache-types.ts +12 -4
  117. package/src/cache/handle-snapshot.ts +69 -87
  118. package/src/cache/index.ts +4 -0
  119. package/src/cache/shell-snapshot.ts +35 -5
  120. package/src/cache/types.ts +21 -10
  121. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  122. package/src/client-urls/client-root.tsx +1 -0
  123. package/src/client-urls/navigation.ts +10 -0
  124. package/src/client-urls/optimistic-location.ts +10 -4
  125. package/src/handles/deferred-resolution.ts +31 -0
  126. package/src/prerender/build-shell-capture.ts +11 -9
  127. package/src/router/manifest.ts +2 -0
  128. package/src/router/match-middleware/cache-lookup.ts +11 -18
  129. package/src/router/navigation-snapshot.ts +25 -6
  130. package/src/router/request-classification.ts +15 -12
  131. package/src/router/router-interfaces.ts +2 -1
  132. package/src/router/router-options.ts +17 -7
  133. package/src/router/segment-resolution/loader-cache.ts +95 -60
  134. package/src/router-versions.ts +60 -0
  135. package/src/rsc/full-payload.ts +6 -2
  136. package/src/rsc/handler-context.ts +5 -0
  137. package/src/rsc/handler.ts +14 -4
  138. package/src/rsc/rsc-rendering.ts +12 -1
  139. package/src/rsc/shell-build-manifest.ts +10 -7
  140. package/src/rsc/shell-capture.ts +36 -26
  141. package/src/rsc/types.ts +4 -6
  142. package/src/server/build-version-table.ts +115 -0
  143. package/src/server/handle-store.ts +344 -127
  144. package/src/server/request-context.ts +62 -11
  145. package/src/ssr/entry-preload-priority.ts +308 -0
  146. package/src/ssr/index.tsx +46 -9
  147. package/src/ssr/preinit-client-references.ts +17 -5
  148. package/src/ssr/ssr-root.tsx +16 -4
  149. package/src/testing/build-versions.ts +64 -0
  150. package/src/testing/dispatch.ts +6 -0
  151. package/src/testing/dom.entry.ts +1 -0
  152. package/src/testing/flight.entry.ts +1 -0
  153. package/src/testing/index.ts +3 -0
  154. package/src/testing/internal/context.ts +2 -1
  155. package/src/testing/internal/seed-vars.ts +14 -2
  156. package/src/testing/location-state-key.ts +18 -8
  157. package/src/testing/render-handler.ts +2 -1
  158. package/src/testing/render-route.tsx +277 -43
  159. package/src/testing/serve-shell-request.ts +80 -11
  160. package/src/testing/vitest-stubs/version.ts +4 -1
  161. package/src/types/handler-context.ts +8 -5
  162. package/src/vite/discovery/build-versions.ts +1281 -0
  163. package/src/vite/discovery/installed-packages.ts +159 -0
  164. package/src/vite/discovery/prerender-collection.ts +38 -1
  165. package/src/vite/discovery/router-versions-phase.ts +336 -0
  166. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  167. package/src/vite/discovery/state.ts +19 -0
  168. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  169. package/src/vite/encryption-key.ts +101 -9
  170. package/src/vite/plugin-types.ts +48 -6
  171. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  172. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  173. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  174. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  175. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  176. package/src/vite/plugins/version-plugin.ts +19 -24
  177. package/src/vite/plugins/version.d.ts +17 -2
  178. package/src/vite/plugins/virtual-entries.ts +40 -7
  179. package/src/vite/rango.ts +30 -3
  180. package/src/vite/router-discovery.ts +82 -13
  181. package/src/vite/utils/client-chunks.ts +84 -14
  182. package/src/vite/utils/compare-strings.ts +9 -0
  183. package/src/vite/utils/package-resolution.ts +9 -0
  184. package/src/vite/utils/prerender-utils.ts +4 -1
  185. package/src/vite/utils/shared-utils.ts +1 -1
@@ -47,7 +47,7 @@ function CurrentPage() {
47
47
  }
48
48
  ```
49
49
 
50
- Returns the pathname string without search params or hash. Updates on navigation commit.
50
+ Returns the pathname string without search params or hash. It is the pathname of the page on screen: it changes when a navigation's destination commits, Back/Forward included (see [When the URL hooks change](#when-the-url-hooks-change)).
51
51
 
52
52
  ### useSearchParams()
53
53
 
@@ -109,6 +109,45 @@ Options: `replace` (default false — push), `scroll` (default true),
109
109
  `revalidate` (default true; `false` skips the server fetch — legal because
110
110
  the setter never changes the pathname).
111
111
 
112
+ ### When the URL hooks change
113
+
114
+ `usePathname()`, `useSearchParams()` and `useParams()` describe the page on
115
+ screen. They change in the commit that shows a navigation's destination, not
116
+ when the navigation starts. That holds for Back/Forward as it does for a link
117
+ click or `router.push()`.
118
+
119
+ | Navigation | The hooks change |
120
+ | ------------------------------------------------- | -------------------------------------------------------------------------- |
121
+ | `<Link>`, `router.push()` / `router.replace()` | when the destination commits |
122
+ | Back/Forward, entry in the client's history cache | with the restored page, in one commit |
123
+ | Back/Forward, entry fetched again | when the fetched page commits; until then the page being left is on screen |
124
+ | a Back/Forward whose fetch fails | with the error boundary: they report the entry the browser is on |
125
+ | `router.push(url, { revalidate: false })` | at once: nothing is fetched and the page stays |
126
+ | inside an optimistic `clientUrls()` destination | at the click, for that branch only (see `/client-urls`) |
127
+
128
+ **The hooks can disagree with the address bar.** On Back/Forward the browser
129
+ changes the URL and `history.state` before it tells the router. When the
130
+ entry has to be fetched (it left the client's history cache, or the cache was
131
+ cleared), the page being left stays on screen for the wait and the hooks keep
132
+ reporting its URL, while `window.location` is already the destination's. That
133
+ is deliberate: an active-link highlight, a breadcrumb or a filter panel
134
+ derived from the hooks stays in step with the content next to it. For
135
+ anything you render, read the hooks, not `window.location`.
136
+
137
+ For pending UI during that wait read `useNavigation()` (see
138
+ [`./navigation.md`](./navigation.md)): `state === "loading"` and `pendingUrl`
139
+ (the entry being fetched) while the request is out, `isStreaming` for the
140
+ whole wait. A Back/Forward served from the history cache has no wait.
141
+
142
+ `setSearchParams((prev) => ...)` builds on the same location: called during a
143
+ pending Back/Forward, `prev` is the search of the page on screen.
144
+
145
+ One gap (issue #1046): a component that first mounts in the page being left
146
+ after the navigation's response has arrived, while React still holds the
147
+ destination behind a loader or a `transition()`, reads the destination's URL
148
+ and params from these hooks. Components that were already mounted are not
149
+ affected. It applies to a push and to Back/Forward alike.
150
+
112
151
  ### useHref()
113
152
 
114
153
  Mount-aware href for client components inside `include()` scopes:
@@ -370,3 +370,16 @@ apps; each app owns its own document.
370
370
 
371
371
  **Within-app** navigation is unchanged — a normal soft SPA update (the document
372
372
  stays mounted). Only crossing an app boundary triggers the reload.
373
+
374
+ ## Deploys and the cache
375
+
376
+ Each `createRouter()` app has its own cache version, computed by `vite build` from that app's built code, so deploying a change to one app does not clear the cache of the others. Lazily mounting sub-apps (`.lazy(() => import("./apps/admin"))`) is what keeps them independent: an app's version covers everything the modules that statically import its router can run, and a lazy mount is where that stops. Routers that one module imports statically share that module's code, so a change to either moves both versions.
377
+
378
+ | Deploy | Cached data | Stored HTML | Open tabs |
379
+ | ------------------------------------- | ------------------- | --------------------- | ----------------------- |
380
+ | Rebuild, no code change | kept | kept | untouched |
381
+ | Server code of app A changes | cleared for A only | cleared for A only | A's tabs reload |
382
+ | Client code of any app changes | kept for every app | cleared for every app | every app's tabs reload |
383
+ | Server code shared by A and B changes | cleared for A and B | cleared for A and B | A's and B's tabs reload |
384
+
385
+ The host entry (the file that creates the host router and its middleware) is not part of a lazily mounted sub-app's version. If host middleware changes what an app renders from inside a cache boundary, change that app's `version` or invalidate its tags. See `/cache-guide`.
@@ -238,6 +238,12 @@ always activates. `when` is not re-evaluated during action revalidation, so
238
238
  an open modal stays open after an action. A selector that throws does not
239
239
  intercept: the full page renders and the error is logged with the route name.
240
240
 
241
+ The server learns `from` from the `X-RSC-Router-Client-Path` header, which the
242
+ rango client sends on every navigation. A partial request without it falls
243
+ back to `Referer`, and only when that `Referer` is on the request's own origin
244
+ (scheme, host and port). A request with no usable navigation context is not
245
+ intercepted: it gets the full page.
246
+
241
247
  A prefetch of a route an intercept targets is stored per source page, whether
242
248
  or not the intercept applies from that page (`rsc-rendering.ts`
243
249
  `x-rsc-prefetch-scope: source`, set from `MatchResult.interceptTargeted`). A
@@ -612,6 +612,10 @@ included. A handler's `ctx.use(Loader)` read never caches by itself (see
612
612
  body, gets the binding's value: one value per loader per request, a HIT
613
613
  included. A reader that runs before the binding starts (a layout's loader
614
614
  reading a route's cached loader) gets the value of its own run, as before.
615
+ This is by design: that loader runs live while the binding serves its entry,
616
+ so its data and its handle push can come from different runs on that request.
617
+ To keep them together, declare the cached loader first or read it from the
618
+ handler.
615
619
 
616
620
  The loader's data is cached independently from the route's segment cache,
617
621
  using the same `SegmentCacheStore` (app-level or per-loader override).
@@ -648,6 +652,14 @@ rejects) skips the whole entry, as above. Entries written
648
652
  before handles were recorded replay none until they are rewritten (expiry or
649
653
  SWR revalidation).
650
654
 
655
+ On a `ppr` route the shell keeps a copy of an `ssr: false` loader's settled
656
+ pushes too. The two never disagree on the page: where a replay of the shell
657
+ serves the loader's data from the shell, the shell's copy is the one shown
658
+ and the entry only adds the deferred push the shell could not keep; where it
659
+ does not (the loader runs or reads its entry), the entry's pushes take the
660
+ shell copy's place, none if the entry recorded none. See `/ppr` → "On a shell
661
+ HIT".
662
+
651
663
  ### Cache Key
652
664
 
653
665
  The default cache key is `loader:{loaderId}:{host}{pathname}:{sortedParams}`
@@ -993,7 +1005,13 @@ on document loads (`metadata.handlesLate`) or progressively on navigations.
993
1005
  A push before your slow fetch usually beats the barrier; a push derived from
994
1006
  the fetched data usually does not. When it MUST be in the document, use
995
1007
  `ssr: false` below. A loader with its own `cache()` keeps its pushes on a hit —
996
- they are replayed from the entry (see "Opting a Loader into Caching").
1008
+ they are replayed from the entry (see "Opting a Loader into Caching"). On a
1009
+ `ppr` route the race does not apply to a document served from its shell:
1010
+ there every push a loader makes on that request applies post-hydration (see
1011
+ `/ppr`, "Handles on a shell HIT"). A `useHandle` reader inside this loader's
1012
+ `loading()` or `<Suspense>` hydrates with the data its HTML was rendered
1013
+ from and shows a later push right after, so a push after an `await` causes
1014
+ no hydration mismatch there.
997
1015
 
998
1016
  Under `cache()`, loader pushes are not stored with the cached segments: the
999
1017
  loader re-runs on every hit and its live push is the only copy. (`ppr` keeps
@@ -426,6 +426,8 @@ export const requireAuthMiddleware: Middleware = async (ctx, next) => {
426
426
 
427
427
  Read the flash on the target page with `useLocationState(FlashMessage)`. The `{ flash: true }` option makes it auto-clear after first render. See `/hooks`.
428
428
 
429
+ A redirect is followed on a link click, `router.push()`, and Back/Forward to an entry the client has to fetch. On Back/Forward the redirect replaces the history entry it was triggered from: Back from the target lands on the entry before the redirecting one, not on the redirecting one again.
430
+
429
431
  ## Authentication Middleware
430
432
 
431
433
  ```typescript
@@ -53,7 +53,8 @@ implements the shell family (`getShell`/`putShell`): `MemorySegmentCacheStore`
53
53
  `VercelCacheStore` (Vercel Runtime Cache). A ppr route on a store without the
54
54
  family renders normally (no shell) with a once-per-key warning. A custom store
55
55
  returns each `ShellCacheEntry` field as `putShell` received it, including
56
- `buildVersion` and `snapshot` (both required; the snapshot can be empty).
56
+ `buildVersion` (the router's document version) and `snapshot` (both required;
57
+ the snapshot can be empty).
57
58
 
58
59
  ```typescript
59
60
  import { createRouter, urls } from "@rangojs/router";
@@ -250,7 +251,9 @@ under `cache()` and under the shell alike.
250
251
 
251
252
  Invalidation crosses layers: `updateTag()`/`revalidateTag()` reach segment,
252
253
  shell, loader, and item entries in the same store, and shell entries
253
- additionally self-invalidate on `React.version` change.
254
+ additionally self-invalidate on `React.version` change and when the router's
255
+ document version changes (server code, SSR output, client asset names, `base`
256
+ or Prerender payloads changed), not on every deploy.
254
257
 
255
258
  ## The serve pipeline: commit after ALL middleware
256
259
 
@@ -383,10 +386,12 @@ encountered by the shell capture.
383
386
  Two more decisions are made before any shell-store read, so probes and
384
387
  prerendered routes never spend passive `getShell` I/O:
385
388
 
386
- - A partial request carrying neither `X-RSC-Router-Client-Path` nor `Referer`
387
- (a curl probe, a synthetic monitor) can never produce a partial match; it
388
- reports `BYPASS; reason=no-navigation-context`. Alert on replay hit-rate
389
- with this in mind — such probes are not cache misses.
389
+ - A partial request carrying neither `X-RSC-Router-Client-Path` nor a
390
+ same-origin `Referer` (a curl probe, a synthetic monitor) can never produce
391
+ a partial match; it reports `BYPASS; reason=no-navigation-context`. A
392
+ `Referer` on another origin counts as absent: only its pathname would be
393
+ matched, as if it were a route of this app. Alert on replay hit-rate with
394
+ this in mind — such probes are not cache misses.
390
395
  - A `Prerender()` route's partial is served from the build-time prerender
391
396
  store inside matching (a better-than-HIT outcome); it reports
392
397
  `BYPASS; reason=prerender-store`. Its captures never record the page's
@@ -436,7 +441,7 @@ The bounded bypass tokens, grouped by when they are decided:
436
441
  | Token | Decided | Meaning |
437
442
  | ----------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
438
443
  | `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
439
- | `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path`/`Referer`; a partial match is impossible |
444
+ | `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path` and no same-origin `Referer`; a partial match is impossible |
440
445
  | `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) |
441
446
  | `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
442
447
  | `cache-disabled` | pre-read or match | `cache(false)` (pre-read, static) or `condition()` false (decided at the lookup); consumer opt-out is absolute |
@@ -896,31 +901,104 @@ value once it refreshes, while the baked container keeps the capture's. If a
896
901
  hole needs a stable value, give it its own cache (`"use cache"` with a
897
902
  profile, or a loader `cache()`); the shell never provides one.
898
903
 
899
- **Handle pushes from a bake-lane loader appear once.** The capture records
900
- the settled, thenable-free handle pushes of every loader body it runs (the
904
+ **Handle pushes from a bake-lane loader appear once, and they match its
905
+ data.** A loader's title, meta or breadcrumb describes the data next to it,
906
+ so both come from the same run on every way a shell can be replayed: a
907
+ document HIT, a client navigation, a prefetch. The capture records the
908
+ settled, thenable-free handle pushes of every loader body it runs (the
901
909
  prelude rendered them): the `ssr: false` loader's own, those of the loaders
902
- it awaits with `ctx.use()`, and those its own `cache()` entry replays. Every
903
- HIT restores them with the handler layer, and they stand: a bake-lane loader
904
- that does run on the HIT (a hole-carrying one) reads the store, so its settled
905
- pushes, and those a `"use cache"` hit inside its body replays, are dropped
906
- instead of replacing the restored ones. Any handle, deduping or not, shows
907
- each value once, and it is the value the prelude rendered. A loader the route
908
- also registers on the live lane is a hole: its live run replaces its restored
909
- pushes, and those of the loaders it awaits, so they show its live value, even
910
- when a bake-lane loader running on the HIT awaits it. A run that throws or
911
- makes no push shows none of the capture's once its run ends; a hole slower
912
- than the handler barrier shows the shell's copy in the first snapshot until
913
- then. A hole with its own loader `cache()` that hits shows the pushes that
914
- entry recorded, none if it recorded none, matching its data. A dependency the route does not
915
- register, which an `ssr: false` loader awaited at capture outside any live
916
- loader, is on neither lane: its settled pushes stay as the prelude rendered
917
- them, even where a live loader also awaits it, while its data is fresh. To
918
- keep its pushes live, declare it as its own `loader()` on the route. A push the capture cannot record (a
919
- deferred push, or one holding a promise) marks every loader record of the
920
- page to run: those bodies then still run on each HIT, in the background, and
921
- that push reaches the page (from the loader's own `cache()` entry when it
922
- hits) with the run's value; a push that lands after the document's handle snapshot
923
- reaches the client after hydration.
910
+ it awaits with `ctx.use()`, and those its own `cache()` entry replays. What
911
+ a replay does with a loader's recorded pushes follows what it does with that
912
+ loader's data:
913
+
914
+ | The replay serves the loader's data from | Its settled pushes are |
915
+ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
916
+ | the shell (its pinned container) | the shell's: restored with the handler layer, and they stand. A loader that does run on the replay (a hole-carrying one) reads the store, and the settled pushes that run makes are dropped |
917
+ | a run on this request (a live-lane loader, or one the shell has no pin for) | that run's: they replace the shell's copies in place; a run that throws or makes no push shows none |
918
+ | the loader's own `cache()` entry | that entry's: the pushes it recorded, none if it recorded none |
919
+
920
+ So any handle, deduping or not, shows each value once. A pinned loader's
921
+ pushes are the values the prelude rendered, also for the pushes a `"use
922
+ cache"` hit inside its body replays. A loader the route also registers on
923
+ the live lane is a hole: its live run replaces its restored pushes, and those
924
+ of the loaders it awaits, so they show its live value, even when a bake-lane
925
+ loader running on the replay awaits it. On a document HIT the page hydrates
926
+ with the shell's copy and the hole's value takes its place after hydration
927
+ ("Handles on a shell HIT" below). An
928
+ `ssr: false` loader is served from the shell only while the entry holds its
929
+ pin: a snapshot captured by a navigation alone carries none, and a shell over
930
+ `ppr.maxSnapshotBytes` drops them. There the loader runs fresh, and its
931
+ pushes are that run's, like its data.
932
+
933
+ A dependency the route does not register, which an `ssr: false` loader
934
+ awaited at capture outside any live loader, is on neither lane: its settled
935
+ pushes stay as the prelude rendered them, even where a live loader also
936
+ awaits it, while its data is fresh. (They follow the run instead once the
937
+ entry has lost a pin, because the loader that awaits it then runs.) To keep
938
+ its pushes live, declare it as its own `loader()` on the route. A push the
939
+ capture cannot record (a deferred push, or one holding a promise) marks every
940
+ loader record of the page to run: those bodies then still run on each replay,
941
+ in the background, and that push reaches the page (from the loader's own
942
+ `cache()` entry when it hits) with the run's value, on a document HIT and on
943
+ a client navigation alike. On a document HIT it arrives after hydration.
944
+
945
+ **Handles on a shell HIT.** A promise pushed from a loader is live and is
946
+ never in a shell. To have a value in the shell's HTML, push it settled
947
+ (await it) from the `ssr: false` loader, or push it from the handler.
948
+
949
+ Three things follow, in this order of scope:
950
+
951
+ 1. **What is in a shell's HTML** changes only for promise pushes of
952
+ `ssr: false` loaders and of the loaders they await: `push(promise)`, or a
953
+ pushed value that holds a promise. Those run at capture, and the shell
954
+ renders no element for such a push. A loader without `ssr: false` never
955
+ ran at capture, so nothing of it was ever in the shell.
956
+ 2. **On a shell HIT every loader push made during the request arrives after
957
+ hydration**, a live-lane loader's included. The document hydrates with
958
+ exactly the handle data the shell's HTML was rendered from (the handlers'
959
+ pushes and the settled pushes of the loaders that ran at capture), and
960
+ the request's own pushes follow as a normal `useHandle` update.
961
+ 3. **`useHandle` on every document, `ppr` or not:** a reader that React is
962
+ hydrating reads the handle data its HTML was rendered from, wherever its
963
+ boundary hydrates, and takes the newer data right after. So a reader
964
+ inside the `loading()` or `<Suspense>` of a loader that pushes after an
965
+ `await` hydrates clean.
966
+
967
+ | On the HIT's request | In the HIT's HTML | The client |
968
+ | --------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------- |
969
+ | a handler's push, a pinned `ssr: false` loader's settled push | yes (the shell has it) | hydrates with it |
970
+ | a deferred push (`push(promise)`), or a push holding a promise, by a loader | no | gets it right after hydration |
971
+ | a live-lane loader's push | no | gets it right after hydration |
972
+ | the run of an `ssr: false` loader the shell has no pin for | the capture's push | hydrates with the capture's, then shows the run's |
973
+
974
+ A value a loader pushes as a promise is therefore in the HTML of a document
975
+ MISS (the render awaits it) and of a route without `ppr`, and not in the
976
+ HTML of a shell HIT. When a crawler or the first paint needs it, change one
977
+ line:
978
+
979
+ ```ts
980
+ ctx.use(Notes)(db.stockNote(id)); // a promise: live, after hydration on a HIT
981
+ ctx.use(Notes)(await db.stockNote(id)); // settled: baked into the shell
982
+ ```
983
+
984
+ The settled push bakes with the shell and is invalidated with it (its TTL,
985
+ its tags). A promise is not baked, whenever it settles: it is the shape that
986
+ says "per request", the same as a promise in the loader's data.
987
+
988
+ A pinned loader's pushes are exactly the ones its capture made. If its body
989
+ pushes a settled value only on some runs (behind a condition), a replay that
990
+ runs it shows the capture's answer, a push or none, never a later run's next
991
+ to the pinned data. A deferred push is the exception by design: it is
992
+ delivered on every replay.
993
+
994
+ One arrangement mixes sources by design. A loader that reads a
995
+ `cache()`-bound loader with `ctx.use()` before that binding starts (a loader
996
+ declared ahead of it, a parent layout) runs it live while the binding serves
997
+ its entry, so the cached loader's data and its handle push can come from
998
+ different runs on that request. To keep them together, declare the cached
999
+ loader first, or read it from the handler: a handler's `ctx.use()` is where a
1000
+ loader read is baked. An entry such a request writes holds no pushes, and a
1001
+ later HIT of it then shows none for that loader.
924
1002
 
925
1003
  Four hard edges (each e2e/unit-pinned):
926
1004
 
@@ -1187,8 +1265,9 @@ eligible captured segment snapshot, per-request CSP nonce, `streamMode:
1187
1265
  request the route's own `cache()` scope refuses (`cache(false)`, or a
1188
1266
  `condition()` returning false for it), and any store without the shell family.
1189
1267
  A stored shell is invalidated when
1190
- `React.version` changes (postponed state is build-coupled), so deploys
1191
- self-heal via recapture.
1268
+ `React.version` changes (postponed state is build-coupled) or when the router's
1269
+ document version changes, so a deploy that changes the router self-heals via
1270
+ recapture, and a deploy that does not keeps its shells.
1192
1271
 
1193
1272
  The per-request CSP nonce guarantee covers BOTH ways a nonce arrives — the
1194
1273
  `createRouter({ nonce })` provider AND a direct `ctx.set(nonce, value)` token
@@ -1234,13 +1313,13 @@ path(
1234
1313
  );
1235
1314
  ```
1236
1315
 
1237
- | Field | Default | Notes |
1238
- | ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1239
- | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
1240
- | `swr` | — | stale window: serve the stale shell + background recapture |
1241
- | `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below |
1242
- | `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (the bake-lane loader records). The recorded handler layer is exempt. Over the cap the pins are dropped and the shell is stored with its handler layer (warned once per key); bake-lane loaders then read the live store, and drift is repaired client-side |
1243
- | `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell |
1316
+ | Field | Default | Notes |
1317
+ | ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1318
+ | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
1319
+ | `swr` | — | stale window: serve the stale shell + background recapture |
1320
+ | `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below |
1321
+ | `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (the bake-lane loader records). The recorded handler layer is exempt. Over the cap the pins are dropped and the shell is stored with its handler layer (warned once per key); bake-lane loaders then run on every replay and read the live store, their handle pushes come from that run like their data, and drift from the prelude is repaired client-side |
1322
+ | `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell |
1244
1323
 
1245
1324
  A shell never outlives the route `cache()` entry it was captured from: under
1246
1325
  a route `cache()`, the shell is fresh no longer than that entry is (at most
@@ -359,8 +359,12 @@ first, so a runtime capture supersedes the baked entry once it exists.
359
359
  A build-baked shell is not on a wall clock the way a pure runtime `ppr` shell is.
360
360
  It serves from the first request after a deploy and keeps serving until one of:
361
361
 
362
- - **a redeploy** — the `buildVersion` gate retires every build entry (like a
363
- React-version bump); the new build re-bakes;
362
+ - **a redeploy** — the new build ships its own build shell manifest and
363
+ re-bakes. A shell stored at runtime for the route is replaced when the
364
+ router's document version changes, and a change of the prerendered payload
365
+ changes it (a Prerender payload is part of its router's document version, not
366
+ its data version, so cached data is kept); a React-version bump also retires
367
+ it;
364
368
  - **`updateTag`** on a tag the shell carries — drops it, so the next request
365
369
  MISSes and a runtime capture takes over.
366
370
 
@@ -697,7 +701,7 @@ emitted — wrap the route in `Passthrough()` when its segments must re-render.
697
701
  `updateTag()`/`revalidateTag()` evict runtime cache entries. They do not refresh
698
702
  a prerendered route's build-time Flight payload, which is served before the tag
699
703
  system is consulted, and a `cacheTag()` inside a `"use cache"` function that runs
700
- during the build has no runtime effect. A redeploy replaces the payload. To make
704
+ during the build has no runtime effect. A redeploy replaces the payload; because a Prerender payload is part of its router's document version, a deploy whose only change is new prerendered content keeps cached data and replaces stored HTML (shells, document-cache responses) for that router. To make
701
705
  a route tag-invalidatable, serve it from the runtime cache instead (`cache()`,
702
706
  or a `Passthrough()` live handler). A build-baked **ppr shell** is different: a
703
707
  tag it carries does drop it (see "Freshness of a build shell").
@@ -157,8 +157,9 @@ stated, greppable contract.
157
157
  `cacheTag(...tags)`, then `updateTag(...tags)` (awaitable) or `revalidateTag(...tags)`
158
158
  (background). Built-in stores index by tag.
159
159
  - `useLoader` / `useHandle` / `useFetchLoader` are client-only.
160
- - Caches are correctness-first: persistent store keys are version-segmented (no
161
- cross-deploy drift), the forward/back cache is mutation-aware, and
160
+ - Caches are correctness-first: persistent store keys are version-segmented per
161
+ `createRouter()` by a hash of its built code (a deploy that does not change a
162
+ router keeps its cache; one that does reads under new keys), the forward/back cache is mutation-aware, and
162
163
  `createVar({ cache: false })` throws on a **direct** read inside a `cache()`
163
164
  boundary or a `"use cache"` function (a deliberately non-propagating guard). See `/cache-guide` →
164
165
  "Correctness & invalidation".
@@ -422,11 +423,11 @@ npx rango generate src/urls.tsx src/api/
422
423
 
423
424
  Three modes:
424
425
 
425
- | Flag | Behavior |
426
- | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
427
- | (none) | Static parser. Exits with an error, writing nothing, if an `include()` can't be resolved statically (factory or dynamic). |
428
- | `--static` | Static parser, but writes partial output and prints the unresolvable includes as warnings. |
429
- | `--runtime` | Vite-based runtime discovery (full coverage; needs `vite` and `@vitejs/plugin-rsc`). `--config <path>` picks the config. |
426
+ | Flag | Behavior |
427
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
428
+ | (none) | Static parser. Exits with an error, writing nothing, if an `include()` can't be resolved statically (factory or dynamic). |
429
+ | `--static` | Static parser, but writes partial output and prints the unresolvable includes as warnings. |
430
+ | `--runtime` | Vite-based runtime discovery (full coverage; needs `vite`; `@vitejs/plugin-rsc` comes with the router). `--config <path>` picks the config. |
430
431
 
431
432
  ### Auto-detection
432
433
 
@@ -170,7 +170,7 @@ interface RangoOptions<TEnv> {
170
170
  request: Request,
171
171
  env: TEnv,
172
172
  ) => string | boolean | Promise<string | boolean>;
173
- version?: string; // Client/server version string; defaults to the build VERSION
173
+ version?: string; // Cache version and client/server version; replaces both computed versions (data and document); defaults to the versions `vite build` computes per router
174
174
  originCheck?:
175
175
  | boolean
176
176
  | ((
@@ -215,6 +215,7 @@ rango({
215
215
  headScripts: "preinit", // "preinit" (executing head module scripts) | "preload" (modulepreload hints only)
216
216
  prerender: { onError: "fail" }, // "fail" | "warn" when a Prerender/Static render throws (/prerender)
217
217
  buildEnv: false, // Build-time ctx.env for Prerender/Static handlers (/prerender)
218
+ encryptionKey: process.env.RANGO_ENCRYPTION_KEY, // Stable server-action encryption key (see Notes)
218
219
  });
219
220
  ```
220
221
 
@@ -234,8 +235,14 @@ Notes:
234
235
 
235
236
  - `clientChunks` default groups app client components by the directory after a
236
237
  route root (`routes/`, `app/`, `pages/`, `features/`, `handlers/`, ...), so
237
- `routes/dashboard/**` becomes chunk `app-dashboard`. Flat folders such as
238
- `src/components/` stay in the shared chunk. `false` restores
238
+ `routes/dashboard/**` becomes chunk `app-dashboard`. An `app/` directly
239
+ followed by another marker with a directory after it is a source root, so
240
+ `app/routes/product/**` is `app-product`; `app/<segment>/**`,
241
+ `app/components/**` and files directly in `app/routes/` keep `app-<segment>`
242
+ / `app-components` / `app-routes`. Flat folders such as
243
+ `src/components/` stay in the shared chunk. A group is the loading unit:
244
+ rendering any member downloads the whole group's chunk;
245
+ `DEBUG=rango:chunks vite build` logs each group's size. `false` restores
239
246
  `@vitejs/plugin-rsc`'s grouping (one client chunk per router). A function
240
247
  `(meta) => string | undefined` names the group per module (`undefined` keeps
241
248
  the default); `directoryClientChunks(meta)`, the default strategy, is
@@ -248,6 +255,19 @@ Notes:
248
255
  renders and PPR shell capture. Unset, documents whose route has a
249
256
  `loader(Def, { ssr: false })` raise it automatically so that content stays
250
257
  in place; see `/loader`.
258
+ - `encryptionKey` is the key that encrypts inline server-action bound
259
+ arguments: base64-encoded 32 bytes (`openssl rand -base64 32`). Pass it as
260
+ `rango({ encryptionKey: process.env.RANGO_ENCRYPTION_KEY })`, never as a
261
+ literal. It is validated when `rango()` is called (invalid base64 or a wrong
262
+ size throws). `undefined` falls back to the `RANGO_ENCRYPTION_KEY`
263
+ environment variable, then to a key generated per build. The key is part of
264
+ the cache version of every router whose server code encrypts inline-action
265
+ bound arguments, so without a stable key those routers get a new version (and
266
+ a cleared cache) on every build; the build prints one note when that applies.
267
+ A router that renders no such action keeps its cache without one (`/use-cache`,
268
+ `/cache-guide`).
269
+ - `createRouter({ version })` takes effect on every preset, including `node`
270
+ and `vercel`. It replaces both computed cache versions with that exact value.
251
271
  - `@rangojs/router/vite` also exports `poke()`, a dev-server plugin: type `e`
252
272
  then Enter (or Ctrl+R where the terminal passes it through) to full-reload
253
273
  the browser.
@@ -89,32 +89,35 @@ The single rule that drives everything:
89
89
 
90
90
  Each primitive links to its sub-file (API + recipe + caveats).
91
91
 
92
- | The behavior is… | Layer | Primitive | Import root |
93
- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------- |
94
- | a `reverse` / `href` / params / env TYPE contract (a wrong name or missing param must not compile) | types (tsc) | [`@ts-expect-error` / `expectTypeOf`](./reverse-and-types.md) | `@rangojs/router/client` + `tsc` |
95
- | one loader's data logic | unit (node) | [`runLoader`](./loader.md) | `@rangojs/router/testing` |
96
- | a loader's cookie / header / thrown `redirect()`/`notFound()` / handle-push output | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
97
- | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
98
- | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
99
- | a `transition({ when })` browser predicate against `{ kind, from, to, action }` | unit (node) | [`runTransitionWhen`](./predicates.md) (`{ applied, gatedOff, context }`) | `@rangojs/router/testing` |
100
- | a `clientUrls()` `revalidate()` / `isAction(ref)` predicate | unit (node) | [`runClientRevalidate`](./predicates.md) (production chain evaluator + locked defaults) | `@rangojs/router/testing` |
101
- | a handle's `collect`/accumulator, a seeded handle read, or a loader handle write | unit | [`collectHandle` / seeded `handles` / `handlePushes`](./handles.md) | `@rangojs/router/testing` |
102
- | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
103
- | a stale indicator from `useLoader().isLoading` while a `transition()` navigation re-runs the loader | unit (DOM) | [`renderRoute` + `navigate(url, { loaders })`](./client-components.md#held-navigation) | `@rangojs/router/testing/dom` |
104
- | a component reading `useLocationState(Def)` after `router.push(url, { state: [Def(value)] })` | unit (DOM) | [`renderRoute` + `withLocationStateKey`](./client-components.md#location-state) | `@rangojs/router/testing[/dom]` |
105
- | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
106
- | a real async **Server Component** (assert what it rendered: typed boundary props, server-rendered host content, inlined-vs-island) | RSC unit | [`renderServerTree` + `findClientBoundaries`/`findElements`](./server-tree.md) | `@rangojs/router/testing/flight` |
107
- | the exact Flight **wire payload** shape (a drift snapshot) | RSC unit | [`renderToFlightString` + `toMatchFlightSnapshot`](./flight.md) | `@rangojs/router/testing/flight` |
108
- | a real route **handler** `(ctx) => rsc` (params/loaders/vars -> rendered RSC + effects) | RSC unit | [`renderHandler`](./render-handler.md) | `@rangojs/router/testing/flight` |
109
- | navigation, hydration, PE parity, view transitions, real SSR | e2e | [`createRangoE2E` -> `parityDescribe`/`expectParity`](./e2e-parity.md) | `@rangojs/router/testing/e2e` |
110
- | a `"use cache"` hit in a handler (the body runs once across renders; the value round-trips) | RSC unit | [`renderHandler` + seeded `cacheStore`](./render-handler.md#a-use-cache-hit) | `@rangojs/router/testing/flight` |
111
- | a `"use cache"` hit in a loader (the same, in the react-server project) | RSC unit | [`runLoader` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
112
- | a loader's own `cache()` (HIT, `updateTag()` of a tag its body recorded) | RSC unit | [`runLoader` + `cache` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
113
- | cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
114
- | a `ppr` route's shell: MISS -> capture -> HIT, what the shell froze vs what a HIT renders live, tag eviction, navigation replay | RSC unit | [`serveShellRequest`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
115
- | PPR shell HIT/MISS in real HTML (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
116
- | generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
117
- | a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
92
+ | The behavior is… | Layer | Primitive | Import root |
93
+ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
94
+ | a `reverse` / `href` / params / env TYPE contract (a wrong name or missing param must not compile) | types (tsc) | [`@ts-expect-error` / `expectTypeOf`](./reverse-and-types.md) | `@rangojs/router/client` + `tsc` |
95
+ | one loader's data logic | unit (node) | [`runLoader`](./loader.md) | `@rangojs/router/testing` |
96
+ | a loader's cookie / header / thrown `redirect()`/`notFound()` / handle-push output | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
97
+ | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
98
+ | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
99
+ | a `transition({ when })` browser predicate against `{ kind, from, to, action }` | unit (node) | [`runTransitionWhen`](./predicates.md) (`{ applied, gatedOff, context }`) | `@rangojs/router/testing` |
100
+ | a `clientUrls()` `revalidate()` / `isAction(ref)` predicate | unit (node) | [`runClientRevalidate`](./predicates.md) (production chain evaluator + locked defaults) | `@rangojs/router/testing` |
101
+ | a handle's `collect`/accumulator, a seeded handle read, or a loader handle write | unit | [`collectHandle` / seeded `handles` / `handlePushes`](./handles.md) | `@rangojs/router/testing` |
102
+ | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
103
+ | a stale indicator from `useLoader().isLoading` while a `transition()` navigation re-runs the loader | unit (DOM) | [`renderRoute` + `navigate(url, { loaders })`](./client-components.md#held-navigation) | `@rangojs/router/testing/dom` |
104
+ | a component reading `useLocationState(Def)` after `router.push(url, { state: [Def(value)] })` | unit (DOM) | [`renderRoute` + `withLocationStateKey`](./client-components.md#location-state) | `@rangojs/router/testing[/dom]` |
105
+ | location state while a navigation is pending (a loader the test holds), or after a back/forward | unit (DOM) | [`navigate(url, { state, loaders })`, a `popstate` event](./client-components.md#state-while-a-navigation-is-pending) | `@rangojs/router/testing[/dom]` |
106
+ | a CLIENT component's hydration: its first client render against its own server HTML (`typeof window`, a late `<Suspense>`) | unit (DOM) | [`renderRoute` + `hydrate: true`](./client-components.md#hydration) | `@rangojs/router/testing/dom` |
107
+ | location state with `clearOnReload`: state after a refresh vs a client navigation | unit (DOM) | [`renderRoute` seeds, with and without `hydrate: true`](./client-components.md#location-state) | `@rangojs/router/testing[/dom]` |
108
+ | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
109
+ | a real async **Server Component** (assert what it rendered: typed boundary props, server-rendered host content, inlined-vs-island) | RSC unit | [`renderServerTree` + `findClientBoundaries`/`findElements`](./server-tree.md) | `@rangojs/router/testing/flight` |
110
+ | the exact Flight **wire payload** shape (a drift snapshot) | RSC unit | [`renderToFlightString` + `toMatchFlightSnapshot`](./flight.md) | `@rangojs/router/testing/flight` |
111
+ | a real route **handler** `(ctx) => rsc` (params/loaders/vars -> rendered RSC + effects) | RSC unit | [`renderHandler`](./render-handler.md) | `@rangojs/router/testing/flight` |
112
+ | navigation, hydration, PE parity, view transitions, real SSR | e2e | [`createRangoE2E` -> `parityDescribe`/`expectParity`](./e2e-parity.md) | `@rangojs/router/testing/e2e` |
113
+ | a `"use cache"` hit in a handler (the body runs once across renders; the value round-trips) | RSC unit | [`renderHandler` + seeded `cacheStore`](./render-handler.md#a-use-cache-hit) | `@rangojs/router/testing/flight` |
114
+ | a `"use cache"` hit in a loader (the same, in the react-server project) | RSC unit | [`runLoader` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
115
+ | a loader's own `cache()` (HIT, `updateTag()` of a tag its body recorded) | RSC unit | [`runLoader` + `cache` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
116
+ | cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
117
+ | a `ppr` route's shell: MISS -> capture -> HIT, what the shell froze vs what a HIT renders live, tag eviction, navigation replay | RSC unit | [`serveShellRequest`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
118
+ | PPR shell HIT/MISS in real HTML (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
119
+ | generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
120
+ | a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
118
121
 
119
122
  Cross-references to the DSL skills: `/loader`, `/middleware`, `/server-actions`,
120
123
  `/handler-use`, `/hooks`, `/response-routes`, `/route`, `/caching`, `/prerender`,
@@ -147,6 +150,7 @@ beside their function):
147
150
  | | `collectHandle` | [`handles.md`](./handles.md) |
148
151
  | | `withLocationStateKey` | [`client-components.md`](./client-components.md) |
149
152
  | | `dispatch` | [`response-routes.md`](./response-routes.md) |
153
+ | | `setBuildVersions` | [`serve-shell-request.md`](./serve-shell-request.md) |
150
154
  | | `assertCacheStatus`, `assertCacheDecision`, `parseCacheHeader`, `createCacheSink`, `filterCacheDecisions` | [`cache-prerender.md`](./cache-prerender.md) |
151
155
  | | `assertShellStatus`, `parseShellStatus`, `shellCacheKey`, `SHELL_STATUS_HEADER`, `assertPprReplayStatus`, `parsePprReplayStatus`, `PPR_REPLAY_STATUS_HEADER` | [`cache-prerender.md`](./cache-prerender.md) |
152
156
  | | `assertGeneratedRoutesMatch`, `diffGeneratedRoutes` | [`reverse-and-types.md`](./reverse-and-types.md) |