@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.19.1",
3
+ "version": "0.21.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -217,7 +217,6 @@
217
217
  "@playwright/test": "^1.49.1",
218
218
  "@testing-library/react": ">=16",
219
219
  "@vercel/functions": "^3.0.0",
220
- "@vitejs/plugin-rsc": "^0.5.35",
221
220
  "react": ">=19.2.8 <20",
222
221
  "react-dom": ">=19.2.8 <20",
223
222
  "vite": "^8.0.16",
@@ -15,7 +15,7 @@ Your app builds in three Vite environments — `client`, `ssr`, and `rsc` — an
15
15
  1. **Server code leaking into the client.** A file that imports `node:fs`, calls a database, or contains action logic ends up in the client bundle because a client component pulled it in transitively. Symptom: your client bundle is much larger than expected, sometimes with imports that fail at runtime.
16
16
  2. **Both dev and prod React in the SSR/RSC bundle.** When `process.env.NODE_ENV` isn't folded at build time, React's CJS files ship both `.development.js` _and_ `.production.js` variants — doubling React's footprint. The Cloudflare Vite plugin and rango's `vercel` preset fold NODE_ENV automatically; on the default `node` preset, `vite build` does it for the client but not always for SSR/RSC, so add the `define` from Step 2.
17
17
  3. **An oversized routes-manifest in your RSC worker.** The `virtual:rsc-router/routes-manifest/<routerId>` chunk holds your route trie and precomputed entries — large only in proportion to your route count. If it's surprisingly big, you may have unintentionally generated routes (e.g., parametrized fixtures) that bloated the trie.
18
- 4. **Poor client-chunk grouping.** `rango({ clientChunks })` decides how `"use client"` modules are grouped into browser chunks. The default groups them by route directory (`routes/<id>/**` → `app-<id>`), with flat folders like `src/components/` in one shared chunk; `clientChunks: false` ships one client chunk per router. A route that pulls in unrelated routes' client code, or one oversized shared chunk, points at the grouping.
18
+ 4. **Poor client-chunk grouping.** `rango({ clientChunks })` decides how `"use client"` modules are grouped into browser chunks. The default groups them by route directory (`routes/<id>/**` → `app-<id>`), with flat folders like `src/components/` in one shared chunk; `clientChunks: false` ships one client chunk per router. A group is the loading unit: rendering any one member downloads the whole group's chunk. A route that pulls in unrelated routes' client code, or one oversized shared chunk, points at the grouping; `DEBUG=rango:chunks vite build` logs each group's size at build end.
19
19
 
20
20
  Tree-shaking does _not_ catch generated data inlined as string literals, or data-dependent conditionals like React's `NODE_ENV` branches. You need a visualizer.
21
21
 
@@ -166,24 +166,87 @@ exemption there). The route then serves uncached; see `/ppr`.
166
166
 
167
167
  ### Cross-deploy safety: version-segmented store keys
168
168
 
169
- `CFCacheStore` prefixes every **physical** store key (the CF Cache API URL and
170
- the KV key) with the build version — auto-generated from the
171
- `@rangojs/router:version` virtual module, overridable via the store's `version`
172
- option. A new deploy reads under a new prefix, so it can **never** read a
173
- previous build's entries: no cross-deploy shape drift, and no dead client-chunk
174
- references baked into cached RSC.
175
-
176
- The tradeoff to know: **loader/data caches use the same store**, so they're
177
- version-segmented too. Every deploy is therefore a _cold data cache_ — SWR can't
178
- soften it, because no stale entry exists under the new key. For high-traffic,
179
- frequently-deploying, data-bound apps that's a deploy-time origin warm-up. Decide
180
- deliberately: accept it (correctness over hit-rate), or split the policy — let
181
- the render/edge cache auto-version while a separate data store gets a stable
182
- `version` so its entries survive deploys. (Per-process stores like
183
- `MemorySegmentCacheStore` are cold on every restart anyway; this matters for
184
- persistent stores.) `VercelCacheStore` does not version automatically: pass a
185
- per-deploy `getCache({ namespace })` or its `version` option (see `/vercel`).
186
- See `/caching` for store setup.
169
+ `CFCacheStore` and `VercelCacheStore` prefix every **physical** store key with a
170
+ version, so a build only reads entries that code like its own wrote. The version
171
+ is a hash of the code, computed per `createRouter()` by `vite build`, not the
172
+ build time. Each router has two:
173
+
174
+ - The **data version** (a hash of the router's server code) keys cached RSC
175
+ data: segment entries from `cache()`, `"use cache"` values, loader data.
176
+ - The **document version** (the data version plus the SSR output, the client
177
+ asset file names, `base`, and the router's `Prerender` payloads) keys stored
178
+ HTML (PPR shells, document-cache responses). It is also sent to the browser,
179
+ and a mismatch with the tab's `_rsc_v` reloads the tab.
180
+
181
+ Same code builds to the same versions, so a rebuild or redeploy of unchanged
182
+ code keeps the cache, and a deploy that changes app A keeps app B's cache.
183
+
184
+ | Deploy | Cached data | Stored HTML | Open tabs |
185
+ | ------------------------------------- | ------------------- | --------------------- | ----------------------- |
186
+ | Rebuild, no code change | kept | kept | untouched |
187
+ | Server code of app A changes | cleared for A only | cleared for A only | A's tabs reload |
188
+ | Client code of any app changes | kept for every app | cleared for every app | every app's tabs reload |
189
+ | 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 |
190
+
191
+ "Server code" is everything the app's server can run: its own modules, the
192
+ dependencies it imports (bundled, or on the node preset left external and
193
+ resolved from `node_modules`), and a stylesheet its server code links
194
+ (`import "./x.css"` in a server component, or `import href from "./x.css?url"`
195
+ for a document `<link>`): the stylesheet's hashed URL is in what the server
196
+ renders, so a change to the compiled CSS clears that app's cached data. A
197
+ `clientUrls()` module is client code that also tells the server what to run:
198
+ changing which loaders a route declares, its `loading` or its transition
199
+ counts as a server change; changing the components it renders does not. Under
200
+ a host router, apps are independent when the host mounts them lazily
201
+ (`.lazy(() => import(...))`); routers one module imports statically share
202
+ that module's code.
203
+
204
+ The key families: `CFCacheStore` keys segment entries and `fn:` items with the
205
+ data version and `doc:` responses and `shell2:` shells with the document
206
+ version. `VercelCacheStore` does the same by default (families `s`, `i` data;
207
+ `r`, `h` document). `MemorySegmentCacheStore` has no version; the process is its
208
+ scope.
209
+
210
+ Tag invalidation markers are stored without a version, so `updateTag()` and
211
+ `revalidateTag()` apply to entries of every version, including a version that
212
+ comes back in a rollback.
213
+
214
+ - **Force a clear:** change `version` (`createRouter({ version })`,
215
+ `createRSCHandler({ version })`, `new CFCacheStore({ version })` or
216
+ `new VercelCacheStore({ version })` all use that exact value for both
217
+ versions), or invalidate tags.
218
+ - **Keep the old "every deploy clears the cache" behavior:** set `version` to a
219
+ per-deploy value (a build id), or on Vercel keep a deployment-scoped
220
+ `getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })`.
221
+ - **Separate environments:** two deployments built from the same code have the
222
+ same versions. If they share a store (the same KV namespace), they share
223
+ entries. Give each environment its own KV namespace or cache namespace, or its
224
+ own `version`.
225
+
226
+ Two things give a router a new version on every build, and with it a cold
227
+ cache on every deploy:
228
+
229
+ - **No stable encryption key.** The key that encrypts inline server-action
230
+ bound arguments is part of the version of every router whose code encrypts
231
+ with it. Pass `rango({ encryptionKey: process.env.RANGO_ENCRYPTION_KEY })`
232
+ (see `/use-cache`); the build prints a note when a router needs it.
233
+ - **A `Prerender` or `Static()` handler whose output differs per build**: a
234
+ timestamp, a random id, or an inline action with bound arguments rendered at
235
+ build time (its arguments are encrypted with a random IV, stable key or not).
236
+ - **An import the server build leaves external and cannot read**: a package
237
+ that is not in `node_modules` at build time, a path that does not resolve, a
238
+ URL. The build names it.
239
+
240
+ Every build prints each router's versions and writes what they were computed
241
+ from to `node_modules/.rangojs-router-build/cache-versions.json`. Diff that
242
+ file between two builds to see which chunk or payload moved a version; its
243
+ `unownedFiles` lists the server files in no router's version (the host entry
244
+ of lazily mounted apps), where a change keeps every app's cache.
245
+
246
+ A custom persistent store keys the same way with `getCacheVersions()` from
247
+ `@rangojs/router/cache`, called per operation inside the request: `data` for
248
+ segment entries and items, `document` for responses and shells, no version on
249
+ its tag-invalidation records. See `/caching` for store setup.
187
250
 
188
251
  ### Client cache: forward/back is mutation-aware
189
252
 
@@ -226,8 +289,9 @@ recompute for a merely-aging entry.
226
289
  `/document-cache`. A platform CDN may independently consume the same header
227
290
  and bypass the function on hits; see `/deployment-caching`.
228
291
 
229
- SWR softens normal TTL expiry, **not** a cross-deploy cold cache — a new build
230
- has no stale entry to serve (see version-segmented store keys above).
292
+ SWR softens normal TTL expiry, **not** a version change — a deploy that changes
293
+ a router's code reads under a new version and has no stale entry to serve (see
294
+ version-segmented store keys above).
231
295
 
232
296
  Store support is layer-specific. `CFCacheStore` and `VercelCacheStore` support
233
297
  SWR for segment, document/response, item (`"use cache"` and cached loaders), and
@@ -564,9 +564,13 @@ const router = createRouter<AppBindings>({
564
564
  });
565
565
  ```
566
566
 
567
- `CFCacheStore` prefixes every key with the build version, so a deploy never
568
- reads the previous build's entries (override with `version`). See `/cache-guide`
569
- → "Cross-deploy safety" and `/cloudflare` for bindings.
567
+ `CFCacheStore` prefixes every key with a version computed per `createRouter()`
568
+ from that router's built code: segment entries and `fn:` items with the data
569
+ version, `doc:` responses and `shell2:` shells with the document version. A
570
+ deploy that changes a router's code reads under a new version; a deploy that does
571
+ not keeps the entries. Tag invalidation markers carry no version, so `updateTag()`
572
+ reaches entries of every version. `version` replaces both versions with one
573
+ value. See `/cache-guide` → "Cross-deploy safety" and `/cloudflare` for bindings.
570
574
 
571
575
  ### With KV L2 Persistence
572
576
 
@@ -755,10 +759,11 @@ the store adds SWR, tag invalidation, and the family split on top of it.
755
759
  import { getCache, waitUntil } from "@vercel/functions";
756
760
  import { VercelCacheStore } from "@rangojs/router/cache";
757
761
 
758
- // A per-deploy namespace: Vercel does not reconcile entries across deploys.
762
+ // No deployment id in the namespace: the store versions its keys itself, so a
763
+ // deploy that does not change a router keeps that router's entries.
759
764
  // One handle per process: the store keeps its PPR shell and tag-marker
760
765
  // memos per handle.
761
- const runtimeCache = getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID });
766
+ const runtimeCache = getCache();
762
767
 
763
768
  const router = createRouter({
764
769
  document: Document,
@@ -794,6 +799,12 @@ beyond the per-item cap are dropped with a warning. The official client swallows
794
799
  `expireTag` failures, so treat `updateTag()` on Vercel as best-effort. See
795
800
  `/vercel` for the preset and the remaining options (`version`, `name`, `debug`).
796
801
 
802
+ `VercelCacheStore` versions its keys by default: families `s` and `i` take the
803
+ router's data version, `r` and `h` its document version, and tag markers
804
+ (`rg:tm:{tag}`) take none. `version` replaces both. To clear the cache on every
805
+ deploy, set `version` to a per-deploy value or keep a deployment-scoped
806
+ `getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })`.
807
+
797
808
  ## Cache purity & tainted objects
798
809
 
799
810
  A `cache()` boundary caches everything except loaders, so anything read inside a
@@ -376,6 +376,22 @@ a hard load of the target URL renders the full route.
376
376
  throwers in the client `ErrorBoundary`; `useFetchLoader` works unchanged
377
377
  (route-independent lane; `revalidate()` deliberately not consulted).
378
378
  Pinned in the router repo's client-urls hook-probe e2e.
379
+ - **Location state follows the same split as the route hooks.** Inside the
380
+ optimistic branch (the destination and the group's client layouts)
381
+ `useLocationState` reads the state the navigation carries (`<Link state>`,
382
+ `router.push(url, { state })`) from the first optimistic render, before the
383
+ server answers: a product page can show the name its link carried while
384
+ its loader streams. Chrome outside the group, and content a navigation
385
+ keeps on screen (a same-route navigation, a destination that suspends with
386
+ no boundary), keep the committed entry's state until the canonical commit.
387
+ State a redirect carries arrives with that commit (an action's
388
+ `setLocationState` without a redirect is applied when its response
389
+ arrives), and a navigation that is superseded or cancelled leaves none
390
+ behind. A `flash` slot read in the branch is cleared from history once the
391
+ navigation commits, not before. In the branch the value is the object the
392
+ navigation passed; from the commit on it is the entry's copy (a structured
393
+ clone), so an object-valued slot is a new, equal object at that point.
394
+ Do not key an effect on its identity across the two.
379
395
  - **Editing the module in dev:** projections refresh via HMR discovery; if a
380
396
  route-shape edit ever serves stale routes, restart dev — and if you are
381
397
  developing the router itself, rebuild the router dist before `pnpm dev`
@@ -48,7 +48,8 @@ export default defineConfig({
48
48
  Worker runs the RSC entry and loads SSR inside the same isolate.
49
49
 
50
50
  `buildEnv: "auto"` gives `Prerender` and `Static` handlers a real `ctx.env` at
51
- build time through Wrangler's `getPlatformProxy()` (local bindings). Omit it
51
+ build time through Wrangler's `getPlatformProxy()` (local bindings), reading
52
+ the wrangler config from the Vite root whatever the working directory. Omit it
52
53
  (default `false`) when build-time rendering must not touch bindings; `ctx.env`
53
54
  then throws during build rendering. See `/prerender`.
54
55
 
@@ -57,7 +57,7 @@ and multi-region composition without replacing its routing or data model.
57
57
  | Middleware | Global + segment-scoped subtree | Single root Proxy (matcher filters paths); no subtree scope | Request/server-function middleware | [Hono middleware + handler interceptors](https://waku.gg/#interceptors) |
58
58
  | Observability | Built-in CF + Vercel OTel phase spans, `Server-Timing`, perf waterfall | Built-in OTel spans; no equivalent router-phase waterfall | Client/data devtools | No equivalent built-in phase model |
59
59
  | Deploy targets | Node, Cloudflare, Vercel — all with presets | Node/Vercel first; other platforms through adapters | Broad Vite/Rsbuild runtime support | Node plus adapters |
60
- | Deployment skew | Automatic build-version handshake for navigation, prefetch, and actions; safe reload on mismatch | `deploymentId` mismatch reload; Vercel can pin clients to an immutable deploy | Application/platform policy | Build-id mismatch reload (v1.0-beta) |
60
+ | Deployment skew | Automatic version handshake for navigation, prefetch, and actions; safe reload on mismatch | `deploymentId` mismatch reload; Vercel can pin clients to an immutable deploy | Application/platform policy | Build-id mismatch reload (v1.0-beta) |
61
61
  | Multi-tenant | `createHostRouter()` built-in | Roll your own | Roll your own | Roll your own |
62
62
  | Testing primitives | Ships `@rangojs/router/testing` (handlers/loaders/mw/Flight/e2e) | No comparable handler/Flight primitives | Documented patterns; no shipped utils | No comparable handler/Flight primitives |
63
63
  | Client runtime | ~50 KB Rango + ~115 KB React/RSC, per-route chunks | Configuration-dependent | Configuration-dependent | Deliberately light |
@@ -336,8 +336,9 @@ first-class primitive.
336
336
 
337
337
  ### Deployment skew is detected before stale code executes
338
338
 
339
- The Vite plugin generates a build version and injects it into the RSC handler and
340
- initial payload. The browser returns that version on every partial navigation,
339
+ `vite build` computes a document version per `createRouter()` from that router's
340
+ built code (the server code, the SSR output, the client asset file names, `base`,
341
+ and its Prerender payloads) and ships it in the initial payload metadata. The browser returns that version on every partial navigation,
341
342
  prefetch, and Server Action. If an old tab reaches a newer server, request
342
343
  classification detects the mismatch before resolving the route or executing the
343
344
  action and responds with `X-RSC-Reload`. The browser then performs a clean document
@@ -348,13 +349,16 @@ falling through to a misleading 404.
348
349
  The protection covers fresh, completed-prefetch, and in-flight-prefetch responses.
349
350
  For action requests, the reload returns to the same-origin referrer rather than the
350
351
  internal action URL. The cache side follows the same correctness rule:
351
- `CFCacheStore` automatically versions its physical Cache API and KV keys, so a new
352
- build cannot replay Flight containing an old component shape or dead client-chunk
353
- reference. `VercelCacheStore` exposes the same version segmentation, but — unlike
354
- `CFCacheStore` — does not default `version` to the build version: skew safety is
355
- opt-in via the `version` option or the recommended deployment-specific Runtime
356
- Cache namespace. The browser's Rango state also includes
357
- the build version, rotating HTTP and in-memory prefetch cache identity on boot.
352
+ `CFCacheStore` and `VercelCacheStore` automatically version their physical keys
353
+ (Cache API and KV keys on Cloudflare): cached RSC data (segment entries, `"use
354
+ cache"` values, loader data) by a data version, a hash of the router's server
355
+ code, and stored HTML (PPR shells, document-cache responses) by the document
356
+ version. A build whose router code changed cannot replay Flight containing an old
357
+ component shape or dead client-chunk reference, and a rebuild of unchanged code
358
+ keeps its cache. Tag invalidation markers carry no version, so `updateTag()`
359
+ reaches entries of every version. The `version` option replaces both versions.
360
+ The browser's Rango state also includes the document version, rotating HTTP and
361
+ in-memory prefetch cache identity on boot.
358
362
 
359
363
  Next.js deserves explicit credit here: its
360
364
  [`deploymentId`](https://nextjs.org/docs/app/api-reference/config/next-config-js/deploymentId)
@@ -587,10 +591,10 @@ dropped rather than warmed. `useLinkStatus()` exposes `{ pending }` for the owni
587
591
  link.
588
592
 
589
593
  **Rango State ties prefetch and client-cache invalidation together.** It is a
590
- session-cookie value shaped as `{buildVersion}:{invalidationTimestamp}`. Navigation
594
+ session-cookie value shaped as `{documentVersion}:{invalidationTimestamp}`. Navigation
591
595
  and prefetch requests send it as `X-Rango-State`, and RSC responses vary on that
592
596
  header, so the browser HTTP cache and Rango's decoded prefetch map share one cache
593
- identity. A deployment changes the version; a mutation rotates the timestamp. Old
597
+ identity. A deployment that changes the router's code or client assets changes the version; a mutation rotates the timestamp. Old
594
598
  responses become unreachable under the retired identity instead of requiring every
595
599
  cache layer to delete the same entries successfully.
596
600
 
@@ -68,6 +68,9 @@ the one for your case.
68
68
  - [`./state.md`](./state.md) — `useLocationState` (persistent + flash state,
69
69
  `.read()`/`.read(location)`/`.write()`/`.delete()`, typed `state` on `router.push()` /
70
70
  `router.replace()`, serializability errors that name the failing field),
71
+ state from another deploy (dropped by the router, no version to manage), the
72
+ `createLocationState` options (`flash`; `clearOnReload` for state that must
73
+ not come back after a refresh, such as a "load more" list),
71
74
  `invalidateClientCache()`.
72
75
  - [`./outlets.md`](./outlets.md) — `Outlet`, `ParallelOutlet`, `useOutlet`.
73
76
  - [`./urls.md`](./urls.md) — `useParams`, `usePathname`, `useSearchParams`,
@@ -39,6 +39,12 @@ the capture, so its pushes always apply client-side; with `ssr: false` they
39
39
  bake into the shell. See `/loader` → "Writing Handles from Loaders". `useHandle`
40
40
  re-renders on every handle update, late loader pushes included.
41
41
 
42
+ On a document load `useHandle` hydrates with the handle data its HTML was
43
+ rendered from, then takes whatever arrived since. That holds wherever the
44
+ reader sits: a reader inside a `<Suspense>` or `loading()` boundary, which
45
+ hydrates after the rest of the page, does not see a late loader push during
46
+ its hydration render, so it cannot mismatch its server HTML.
47
+
42
48
  Handles can be passed as props from server to client components:
43
49
 
44
50
  ```tsx
@@ -27,6 +27,34 @@ function IsLoading() {
27
27
  }
28
28
  ```
29
29
 
30
+ #### Pending UI, Back/Forward included
31
+
32
+ A link click, `router.push()` and a Back/Forward that has to fetch its entry
33
+ report a pending navigation the same way. While the page being left is still
34
+ on screen:
35
+
36
+ | What you want to show | Read |
37
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------- |
38
+ | a request is out, nothing has arrived | `state === "loading"`; `pendingUrl` is the URL being fetched |
39
+ | the page has not fully arrived yet | `isStreaming`: true from the start of the request until the stream ends |
40
+ | which page is on screen while the request runs | `location`, or `usePathname()` / `useSearchParams()` (see [`./urls.md`](./urls.md)) |
41
+
42
+ `state` and `pendingUrl` cover the network wait. Once the response has
43
+ arrived a loader may still be streaming, and React keeps the page being left
44
+ on screen where a navigation holds its content (a same-route change, a
45
+ `transition()` route); `isStreaming` covers that part too, so use it for a
46
+ progress indicator that should last until the new page is complete.
47
+
48
+ On Back/Forward the address bar changes at once, before any of this: during
49
+ the wait `window.location` is the entry being fetched, the same URL as
50
+ `pendingUrl`, and `location` is the page on screen. A Back/Forward served
51
+ from the client's history cache restores its page in one commit and reports
52
+ no pending state.
53
+
54
+ `location` moves when the response arrives. Where React then holds the page
55
+ being left, it is ahead of the screen for that last part of the wait, and so
56
+ is a URL hook in a component that first mounts then (issue #1046).
57
+
30
58
  ### useRouter()
31
59
 
32
60
  Access stable router actions. The returned object never changes identity, so
@@ -84,6 +84,59 @@ definition:
84
84
  const state = useLocationState<{ from?: string }>(); // { from?: string } | undefined
85
85
  ```
86
86
 
87
+ ### When a reader sees an entry's state
88
+
89
+ A reader sees a history entry's location state together with that entry's
90
+ page, never before it. The router keeps the state of the entry on screen next
91
+ to the page it renders and changes both in one React commit. While a
92
+ navigation is pending (its loader still streaming, a `transition()` holding
93
+ the page) what is on screen keeps the state of the entry being left. That
94
+ holds for every reader in it, including one that mounts during the wait: it
95
+ reads the entry on screen, not the one `history.state` already holds.
96
+ Back/forward and state the server sets on a navigation
97
+ (`ctx.setLocationState()`, `redirect(url, { state })`) follow the same rule.
98
+
99
+ | What changes the state | A reader gets it |
100
+ | ------------------------------------------------------------ | ---------------------------------------------------------------- |
101
+ | `<Link state>`, `router.push()` / `router.replace()` | in the commit that shows the destination |
102
+ | back/forward | in the commit that restores the entry (cached or refetched) |
103
+ | the server, on a navigation (`setLocationState`, a redirect) | in the commit that shows the destination |
104
+ | a navigation the server answers with nothing to re-render | with the new location: the page on screen is the entry's |
105
+ | `router.push(url, { revalidate: false })` (no server fetch) | with the new location: the page does not change |
106
+ | a server action's `setLocationState` (no navigation) | when the action's response arrives, added to what readers show |
107
+ | `Def.write()` / `Def.delete()` | when the entry is next restored: back/forward to it, or a reload |
108
+
109
+ Three consequences:
110
+
111
+ - A value keeps its identity until the entry's state changes it. A navigation
112
+ that carries no state re-renders no reader. One that does re-renders each
113
+ reader once, in the commit that already renders the page; a reader of a
114
+ slot the navigation carried forward unchanged (the value it got from the
115
+ hook, passed back) or of a slot a server action did not touch gets the same
116
+ object again, so its effects and memoized children do not run. Any other
117
+ object is a new value, equal content included: a link clicked twice
118
+ announces its state twice. A value is always a copy of what was passed.
119
+ - `Def.write()` and `Def.delete()` change the history entry and nothing a
120
+ reader shows, whether it is mounted or mounts later. `Def.read()` returns
121
+ the written value at once.
122
+ - Outside the router's provider (a second React root, a component rendered
123
+ bare in a test) a reader has no entry and returns `undefined`. Test with
124
+ `renderRoute` (see `/testing`).
125
+
126
+ A reader that mounts with the destination reads the destination's state on
127
+ its first render. Inside an optimistically rendered `clientUrls()` destination
128
+ that is the state the navigation carries, before the server has answered (see
129
+ `/client-urls`, "Pitfalls").
130
+
131
+ The URL hooks follow the same rule for a component that is mounted across the
132
+ navigation: on back/forward too, `usePathname()` and `useSearchParams()`
133
+ change with the page, not at the `popstate` event (see
134
+ [`./urls.md`](./urls.md), "When the URL hooks change"). One difference
135
+ remains (issue #1046): a component that first mounts in the page being left,
136
+ after the response arrived and while React still holds the destination, reads
137
+ its own entry's location state from `useLocationState` and the destination's
138
+ URL and params from `usePathname()`, `useSearchParams()` and `useParams()`.
139
+
87
140
  ### State on router.push() / router.replace()
88
141
 
89
142
  The same `state` option exists on `router.push()` / `router.replace()` (see
@@ -201,9 +254,11 @@ Flash behavior is determined by the definition (`{ flash: true }`), not by which
201
254
  hook reads it. `useLocationState` reads the value during render (on the
202
255
  hydration render it returns `undefined` and reads in a post-mount effect, so
203
256
  SSR and hydration agree), then clears it from `history.state` via
204
- `replaceState` in a `useEffect`. Multiple components reading the same flash
205
- definition all see the value. Pressing back/forward will not re-show the flash
206
- since it was cleared.
257
+ `replaceState` in a `useEffect`. Every component reading the same flash
258
+ definition sees the value, whenever it mounts, until the next navigation or
259
+ back/forward commits its entry's state: an entry that carries no flash shows
260
+ none. A server action's state does not end it. Pressing back/forward will not
261
+ re-show the flash since it was cleared.
207
262
 
208
263
  Set flash state from the server via `redirect()` with state (from a handler,
209
264
  middleware, or server action). Import the definition from the shared module so
@@ -221,7 +276,9 @@ import { FlashMessage } from "../location-states";
221
276
  };
222
277
  ```
223
278
 
224
- Or via `ctx.setLocationState()` on any response (handlers and middleware):
279
+ Or via `ctx.setLocationState()` from a handler or middleware. It is delivered
280
+ with a client navigation's or an action's response; a document response
281
+ carries no location state (there is no history entry to write it to yet):
225
282
 
226
283
  ```tsx
227
284
  (ctx) => {
@@ -230,6 +287,148 @@ Or via `ctx.setLocationState()` on any response (handlers and middleware):
230
287
  };
231
288
  ```
232
289
 
290
+ ### State from another deploy
291
+
292
+ History entries survive reloads and back/forward, so a tab left open across a
293
+ release still holds the state the older build wrote. The router drops it for
294
+ you: every entry records the app version its location state was written under,
295
+ and a read under any other version returns `undefined`, for typed slots and
296
+ for plain state alike. You do not manage a version and there is none to read;
297
+ the app version is the one the router already uses to reload a client that is
298
+ behind the server. It is computed from the app's built code (its server code
299
+ and the client assets), so it changes with a build that changes the app and
300
+ stays the same across a rebuild or redeploy of unchanged code.
301
+
302
+ | An entry's location state, read by | Result |
303
+ | ---------------------------------------------------------------- | ----------- |
304
+ | the version that wrote it: navigation, back/forward, refresh | the value |
305
+ | the same code rebuilt or redeployed | the value |
306
+ | a changed build, after a refresh or a restored tab | `undefined` |
307
+ | a changed build, on back/forward to an entry the older one wrote | `undefined` |
308
+ | any build, when the entry predates this behavior | `undefined` |
309
+
310
+ So location state is for the current session of the current version of the
311
+ app: a filter carried on a link, a flash message, a scroll target. State that
312
+ has to outlive a deploy that changes the app belongs in the URL, a cookie or
313
+ storage. In development a
314
+ server-module edit (HMR) changes the version too; the running page keeps its
315
+ state, and the next document load drops it.
316
+
317
+ ### createLocationState options
318
+
319
+ ```ts
320
+ createLocationState<TState>({
321
+ flash?: boolean, // read once, cleared after paint (above)
322
+ clearOnReload?: boolean, // drop the state on a document load (below)
323
+ });
324
+ ```
325
+
326
+ The value is always stored as-is. `clearOnReload` changes the slot's KEY in
327
+ `history.state` instead, by a suffix on the key the Vite plugin injects
328
+ (`<key>` below, `__rsc_ls_<file>#<ExportName>`):
329
+
330
+ | Options | Key in `history.state` | Reads `undefined` when |
331
+ | --------------- | ---------------------- | ----------------------------------------------- |
332
+ | none, `flash` | `<key>` | the slot is empty (`flash`: once read) |
333
+ | `clearOnReload` | `<key>~r` | the entry's document was loaded since the write |
334
+
335
+ `Def.__rsc_ls_key` is that key, suffix included. `~` cannot appear in an export
336
+ name, so no other definition can end up with a suffixed key.
337
+
338
+ `flash` with `clearOnReload` is rejected: `createLocationState` throws in
339
+ development.
340
+
341
+ ### clearOnReload: state the server did not render with
342
+
343
+ Location state lives in the browser, so the server renders a document without
344
+ it. `useLocationState` therefore hydrates as `undefined` and applies the stored
345
+ value right after. For most state that is invisible. For state that decides
346
+ how much content is on the page, it is a layout shift on every refresh.
347
+
348
+ The case this option exists for is a "load more" list. `?page=6` loads that
349
+ page's 50 products through a loader, and the 250 already on screen ride along
350
+ as location state on the link, so the next page shows them at once and
351
+ streams the new ones:
352
+
353
+ ```ts
354
+ // location-states.ts
355
+ import { createLocationState } from "@rangojs/router";
356
+
357
+ export const CarriedProducts = createLocationState<Product[]>({
358
+ clearOnReload: true,
359
+ });
360
+ ```
361
+
362
+ ```tsx
363
+ "use client";
364
+ import { Link, useLoader, useLocationState } from "@rangojs/router/client";
365
+ import { ProductsLoader } from "./loaders"; // the page named by ?page
366
+ import { CarriedProducts } from "./location-states";
367
+
368
+ export function ProductList() {
369
+ const { data } = useLoader(ProductsLoader);
370
+ const carried = useLocationState(CarriedProducts) ?? [];
371
+ const products = [...carried, ...data.products];
372
+
373
+ return (
374
+ <>
375
+ <ul>
376
+ {products.map((product) => (
377
+ <li key={product.id}>{product.name}</li>
378
+ ))}
379
+ </ul>
380
+ <Link
381
+ to={`/products?page=${data.page + 1}`}
382
+ state={[CarriedProducts(products)]}
383
+ scroll={false}
384
+ >
385
+ Load more
386
+ </Link>
387
+ </>
388
+ );
389
+ }
390
+ ```
391
+
392
+ The list is a plain concatenation. After "Load more" the next page's loader
393
+ streams while the current page stays on screen, and `carried` stays the
394
+ current entry's until that page lands: the carried products and the loader's
395
+ page change in one commit, so no product is ever listed twice.
396
+
397
+ Without the option, a refresh of `?page=6` renders 50 products on the server
398
+ and then inserts the stored 250 above them. With it, the refreshed page stays
399
+ as the server rendered it:
400
+
401
+ | What happens to the entry | A `clearOnReload` slot |
402
+ | ------------------------------------------------------------------------- | -------------------------------------------------------------------- |
403
+ | The reader mounts during a client navigation (`<Link>`, `router.push`) | applied, as without the option |
404
+ | back/forward inside the running app (popstate), a server action's state | applied, as without the option |
405
+ | A document load: refresh, or a back/forward that loads the document | not applied, and removed from `history.state` |
406
+ | Later on that page: a reader mounts, or a navigation returns to the entry | still `undefined`; the next write (the next "Load more") stores anew |
407
+ | The page is restored from the browser's back/forward cache | untouched: nothing is loaded or hydrated |
408
+
409
+ - The rule is any document load of the entry, not only the Reload button:
410
+ restoring a closed tab or duplicating a tab loads the document too.
411
+ - Only the entry being loaded is cleared. Going back from it to an earlier
412
+ entry inside the running app is a client navigation and applies that
413
+ entry's state.
414
+ - The router removes every `~r` key when the client starts, before hydration.
415
+ It goes by the key alone, so it does not matter whether a reader is mounted,
416
+ where it sits (a `<Suspense>` boundary that hydrates late included), or
417
+ whether the definition's module is loaded at all. Other slots on the entry
418
+ are left alone.
419
+ - State the server sets is never dropped by this: a document response carries
420
+ no location state, and `ctx.setLocationState()` / `redirect(url, { state })`
421
+ reach `history.state` only through navigations and actions in the running
422
+ app, after start-up.
423
+ - `.write()` stores under the same key, so it does not survive a refresh
424
+ either.
425
+ - Adding the option to an existing definition moves it to the `~r` key: state
426
+ stored before, under the plain key, is no longer read (and is not removed).
427
+ Removing the option moves it back to the plain key; what was stored under
428
+ `~r` is removed at the next document load.
429
+ - It cannot be combined with `flash`: flash state is removed at its first
430
+ read, so the pair could only drop a message nobody has seen yet.
431
+
233
432
  ### .read() (non-hook access)
234
433
 
235
434
  Read current location state outside React components (client-side only):
@@ -264,9 +463,11 @@ Static counterparts to `.read()`. Both mutate the current history entry's
264
463
  bookkeeping, other location state slots). Both are client-only; they throw
265
464
  when called on the server.
266
465
 
267
- Neither dispatches an event, so components reading via `useLocationState`
268
- will NOT re-render until the next navigation/popstate. Pair with `.read()`
269
- (or a fresh mount via back/forward/reload) instead.
466
+ Neither changes what a `useLocationState` reader shows, mounted or mounting
467
+ later: readers get the entry's state as it was committed (see "When a reader
468
+ sees an entry's state"). The written value is there for `.read()` at once,
469
+ and for readers when the entry is next restored (back/forward to it, or a
470
+ reload).
270
471
 
271
472
  ```tsx
272
473
  "use client";
@@ -275,7 +476,7 @@ import { ProductState } from "./state";
275
476
  // Persisted across hard refresh and back/forward of this entry.
276
477
  ProductState.write({ name: "Widget", price: 9.99 });
277
478
 
278
- // Read later (or on next mount).
479
+ // Read later.
279
480
  const current = ProductState.read();
280
481
 
281
482
  // Manually clear the slot. Idempotent if it isn't set.