@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
@@ -56,3 +56,7 @@ export {
56
56
  } from "./document-cache.js";
57
57
 
58
58
  export type { CacheErrorCategory } from "./cache-error.js";
59
+
60
+ // For a custom persistent store: the versions the built-in stores key with.
61
+ export { getCacheVersions } from "../server/build-version-table.js";
62
+ export type { RouterVersions } from "../router-versions.js";
@@ -18,7 +18,9 @@
18
18
  * the capture's copy of a value: it is dynamic or has its own cache. A
19
19
  * bake-lane loader body that runs on a HIT reads the store too; its pinned
20
20
  * container paths stay the pin's (overlayLoaderContainer), and its settled
21
- * pushes stay the record's (HandleStore.pushRestored).
21
+ * pushes stay the record's (HandleStore.pushRestored). The two go together:
22
+ * a loader whose pin the entry lost runs fresh, and the record's copies of
23
+ * its pushes give way to that run's (restoreHandles).
22
24
  */
23
25
 
24
26
  import type {
@@ -367,17 +369,40 @@ export interface ShellLoaderSeedEntry {
367
369
  * hole-free record: a hole-carrying one always runs.
368
370
  */
369
371
  runs: boolean;
372
+ /**
373
+ * The capture that wrote the pin recorded every settled push of the
374
+ * loader's run in the doc record (CachedEntryData.handleOwners): the pin
375
+ * carries the `runs` bit, which captures write since they record those
376
+ * pushes (v0.18). A replay then drops any other settled push of the loader
377
+ * (HandleStore RecordAuthority "pin"). False for an older pin, whose record
378
+ * may hold none and leaves them to the run.
379
+ */
380
+ complete: boolean;
370
381
  }
371
382
 
383
+ /**
384
+ * A pin's loader. A pin is stored under its loader segment id,
385
+ * `${shortCode}D${index}.${loaderId}` (fresh.ts resolveLoaders), and a
386
+ * shortCode holds no "D" and no "." (server/context.ts getShortCode: M, L, P,
387
+ * R, C, I and digits), so the first `D<index>.` ends it. A loader id can hold
388
+ * both.
389
+ */
390
+ const PIN_KEY_PREFIX = /^[^D.]*D\d+\./;
391
+
372
392
  /**
373
393
  * Materialize the loader-family seed from a shell snapshot for a HIT's tail
374
394
  * render: Flight-deserialize each recorded (promise-elided) bake-lane
375
- * container into a segment-key -> container Map, which serveShellHit assigns
376
- * to the tail context's `_shellLoaderSeed` for the resolveLoaderData overlay.
395
+ * container into a loader-id -> pin Map, which serveShellHit assigns to the
396
+ * tail context's `_shellLoaderSeed` (loader-cache.ts servedPins).
377
397
  * Lives here so every snapshot family is decoded in this module (the segment
378
398
  * family via {@link SeededShellStore}); the loader family is not a store
379
399
  * read, so it seeds the context instead of a store.
380
400
  *
401
+ * Keyed by loader, not by the segment a pin was stored under: a loader runs
402
+ * once per capture, so every segment that registers it pinned the same
403
+ * container, and the loader's value and its handle pushes are both looked up
404
+ * by loader. A key that does not parse pins nothing.
405
+ *
381
406
  * Deserializations run in parallel; a record that fails to decode is skipped
382
407
  * (that loader drifts — the pre-snapshot behavior — instead of failing the
383
408
  * HIT). Returns undefined when the snapshot carries no loader records, without
@@ -396,10 +421,14 @@ export async function buildShellLoaderSeed(
396
421
  const entries = await Promise.all(
397
422
  loaderRecords.map(
398
423
  async (rec): Promise<[string, ShellLoaderSeedEntry] | null> => {
424
+ const prefix = PIN_KEY_PREFIX.exec(rec.key);
425
+ if (!prefix) return null;
399
426
  try {
400
- const stored = rec.value as ShellSnapshotLoaderValue;
427
+ // Typed as always carrying the bits; an older record lacks them.
428
+ const stored = rec.value as Partial<ShellSnapshotLoaderValue> &
429
+ Pick<ShellSnapshotLoaderValue, "value">;
401
430
  return [
402
- rec.key,
431
+ rec.key.slice(prefix[0].length),
403
432
  {
404
433
  container: await deserializeResult(stored.value),
405
434
  // A record stored before the bits existed (v0.17) lacks the
@@ -407,6 +436,7 @@ export async function buildShellLoaderSeed(
407
436
  // so the loader body supplies them.
408
437
  holes: stored.holes !== 0,
409
438
  runs: stored.runs !== 0,
439
+ complete: stored.runs !== undefined,
410
440
  },
411
441
  ];
412
442
  } catch {
@@ -49,6 +49,13 @@ export interface CacheGetResult {
49
49
  * The store deals with serialized data - RSC serialization is handled
50
50
  * by the cache provider layer.
51
51
  *
52
+ * A store that outlives the process has to keep one build from reading what
53
+ * other code wrote. The built-in stores prefix their keys with the serving
54
+ * router's cache versions; a custom store reads the same pair with
55
+ * `getCacheVersions()` (`@rangojs/router/cache`): `data` for segment entries
56
+ * and items, `document` for responses and shells, and no version on its
57
+ * tag-invalidation records, so an invalidation reaches every version.
58
+ *
52
59
  * @typeParam TEnv - Platform bindings type (e.g., Cloudflare env)
53
60
  */
54
61
  export interface SegmentCacheStore<TEnv = unknown> {
@@ -415,13 +422,14 @@ export interface ShellCacheEntry {
415
422
  /** React.version captured at prerender time; the read-time invalidation gate. */
416
423
  reactVersion: string;
417
424
  /**
418
- * Build version captured at prerender time (the RSC handler's `version` —
419
- * the `@rangojs/router:version` build stamp by default, bumped per build and
420
- * on dev RSC-module edits). The second read-time gate: a persistent shared
425
+ * Build version captured at prerender time: the RSC handler's `version`,
426
+ * which is the router's DOCUMENT version (router-versions.ts) — a hash of
427
+ * its server code and the client asset names by default, a stamp bumped on
428
+ * dev RSC-module edits. The second read-time gate: a persistent shared
421
429
  * store (KV/runtime-cache) survives deploys, and an app-code change that
422
430
  * keeps the same React version would otherwise leave a stale-build
423
- * prelude+postponed live under the same key. A custom store returns it as
424
- * putShell received it.
431
+ * prelude+postponed live under the same key. A deploy that changes neither
432
+ * keeps the entry. A custom store returns it as putShell received it.
425
433
  */
426
434
  buildVersion: string;
427
435
  /**
@@ -761,10 +769,13 @@ export type SegmentHandleData = Record<string, unknown[]>;
761
769
  * index (null: not owned). Only a PPR shell capture writes owners: the
762
770
  * settled pushes of the loader bodies it ran (an `ssr: false` loader's own,
763
771
  * the loaders it awaits, and its own cache() replays), which the record keeps
764
- * because the prelude rendered them. A document HIT restores a bake-lane
765
- * owner's value through HandleStore.pushRestored, and it stands against a run
766
- * of its loader there; a live-lane owner's (a hole), and every owner's on
767
- * any other replay of the record (a client navigation), go through
768
- * pushReplayed, so a run of the loader replaces it with its live push.
772
+ * because the prelude rendered them. A restore of the record
773
+ * (handle-snapshot.ts restoreHandles) keeps an owner's value where the
774
+ * request serves that loader from the pin stored with the record
775
+ * (HandleStore.pushRestored: it stands against a run of the loader), on a
776
+ * document HIT and on a navigation replay alike. Every other owner's value
777
+ * is a placeholder (pushPlaceholder): a live-lane loader's, and every
778
+ * owner's where the record has no pin for it. The loader's run, or its own
779
+ * cache() entry, replaces it.
769
780
  */
770
781
  export type HandleOwners = Record<string, Record<string, (string | null)[]>>;
@@ -23,7 +23,8 @@
23
23
  * 3. The platform guardrails: the 2 MB per-item ceiling (oversized writes
24
24
  * silently no-op on Vercel, so we skip + report), the per-item tag cap, and
25
25
  * cross-deploy non-reconciliation (TTL/tag updates are not reconciled across
26
- * deployments - bake a build id into `version` or the getCache namespace).
26
+ * deployments - every key carries the serving router's cache version, see
27
+ * toStoreKey).
27
28
  *
28
29
  * Dependency stance: this module imports NOTHING from `@vercel/functions`. The
29
30
  * runtime cache handle (and `waitUntil`) are injected through the constructor and
@@ -52,6 +53,10 @@ import {
52
53
  _getRequestContext,
53
54
  type RequestContext,
54
55
  } from "../../server/request-context.js";
56
+ import {
57
+ versionKeyPrefix,
58
+ type KeyVersion,
59
+ } from "../../server/build-version-table.js";
55
60
  import { isPerClientSignalHeader } from "../../browser/cookie-name.js";
56
61
  import {
57
62
  resolveTtl,
@@ -170,6 +175,19 @@ const REVALIDATION_LOCK_MS = 30_000;
170
175
  * the suffix; `rg:` namespaces every Rango entry. `h` is the PPR shell tier. */
171
176
  type CacheFamily = "s" | "i" | "r" | "h" | "tm";
172
177
 
178
+ /**
179
+ * Which of the serving router's versions each family's keys carry
180
+ * (versionKeyPrefix). A tag marker carries none: an invalidation applies to
181
+ * every version, including one a rollback brings back.
182
+ */
183
+ const FAMILY_VERSION: Record<CacheFamily, KeyVersion> = {
184
+ s: "data",
185
+ i: "data",
186
+ r: "document",
187
+ h: "document",
188
+ tm: null,
189
+ };
190
+
173
191
  /**
174
192
  * TTL for tag-invalidation marker entries ("tm" family), written by
175
193
  * invalidateTags for isTagsInvalidatedSince (the build-shell read-through,
@@ -177,7 +195,7 @@ type CacheFamily = "s" | "i" | "r" | "h" | "tm";
177
195
  * The platform's expireTag() DELETES tagged entries (no queryable
178
196
  * history), so the markers are rango's own record of "tag X was invalidated
179
197
  * at T". One year: runtime tagged-shell retention is capped to this lifetime,
180
- * while buildVersion retires build shells on the next deploy. An expired marker
198
+ * while a build shell is replaced by the next build's. An expired marker
181
199
  * therefore cannot resurrect a runtime shell that outlived its invalidation.
182
200
  */
183
201
  const TAG_MARKER_TTL_SECONDS = 365 * 24 * 60 * 60;
@@ -299,14 +317,20 @@ export type VercelCacheDebug =
299
317
  export interface VercelCacheStoreOptions<TEnv = unknown> {
300
318
  /**
301
319
  * The Vercel Runtime Cache handle - `getCache()` from `@vercel/functions`.
302
- * Required. Construct it with a build-hash namespace to bust stale-shaped
303
- * entries across deployments, since Vercel does not reconcile TTL/tags between
304
- * deploys:
320
+ * Required.
305
321
  *
306
322
  * ```ts
307
323
  * import { getCache } from "@vercel/functions";
308
- * new VercelCacheStore({ cache: getCache({ namespace: BUILD_ID }) });
324
+ * new VercelCacheStore({ cache: getCache() });
309
325
  * ```
326
+ *
327
+ * Vercel does not reconcile entries between deployments; the store does it
328
+ * through its keys, which carry the serving router's cache versions (see
329
+ * `version`). So a deploy that changes a router's code stops reading that
330
+ * router's old entries, and a deploy that does not keeps them. A namespace
331
+ * that changes per deployment (`getCache({ namespace: VERCEL_DEPLOYMENT_ID })`)
332
+ * still works and clears the cache on every deploy, which is what you want
333
+ * only if you need that.
310
334
  */
311
335
  cache: VercelRuntimeCache;
312
336
 
@@ -336,9 +360,15 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
336
360
  ) => string | Promise<string>;
337
361
 
338
362
  /**
339
- * Build/version id folded into every stored key as `v/{version}/...`. A second
340
- * cross-deploy busting layer in addition to (or instead of) the getCache
341
- * namespace. Changing it invalidates everything this store wrote previously.
363
+ * Version override, folded into every versioned key as `v/{version}/...`.
364
+ * When set, this exact value is used for cached data and stored HTML alike;
365
+ * changing it makes everything this store wrote before unreachable.
366
+ *
367
+ * Leave it unset for the default: the store keys with the versions of the
368
+ * router serving the request, as CFCacheStore does. Segments and `"use
369
+ * cache"` items use the router's data version; responses and PPR shells use
370
+ * its document version (see `createRouter({ version })`). Tag markers are
371
+ * never versioned, so an invalidation applies to every version.
342
372
  */
343
373
  version?: string;
344
374
 
@@ -447,8 +477,8 @@ function tagStamp(tags: string[]): { ta?: number } {
447
477
  * that propagates expireTag rejections does get the strict guarantee, which is
448
478
  * what the unit suite exercises.)
449
479
  *
450
- * Key hashing / family isolation. The store namespaces its three value tiers as
451
- * `rg:{s|i|r}:{key}` so segment, `"use cache"`, and response entries occupy
480
+ * Key hashing / family isolation. The store namespaces its value tiers as
481
+ * `rg:{s|i|r|h}:{key}` so segment, `"use cache"`, response and shell entries occupy
452
482
  * disjoint keyspaces. That separation is only as strong as the cache's
453
483
  * `keyHashFunction`: `getCache`'s default is djb2, which folds every key to 32
454
484
  * bits (8 hex chars), so at a large live-key count a collision can let one
@@ -464,11 +494,9 @@ function tagStamp(tags: string[]): { ta?: number } {
464
494
  *
465
495
  * // One handle per process: getCache() resolves the platform cache on every
466
496
  * // call, and the PPR shell and tag-marker memos are kept per handle.
467
- * // `process.env` (not `import.meta.env`, which needs a VITE_ prefix and
468
- * // would be undefined here) so cross-deploy busting via `version` works.
469
- * const runtimeCache = getCache({
470
- * namespace: process.env.VERCEL_DEPLOYMENT_ID,
471
- * });
497
+ * // No deployment id in the namespace: the store's keys carry the serving
498
+ * // router's cache versions, so a deploy keeps what its code did not change.
499
+ * const runtimeCache = getCache();
472
500
  *
473
501
  * export const router = createRouter({
474
502
  * cache: () => ({
@@ -493,7 +521,8 @@ export class VercelCacheStore<
493
521
 
494
522
  private readonly cache: VercelRuntimeCache;
495
523
  private readonly waitUntil?: (promise: Promise<unknown>) => void;
496
- private readonly version?: string;
524
+ /** The `version` option: used for every versioned family when set. */
525
+ private readonly explicitVersion?: string;
497
526
  private readonly maxItemBytes: number;
498
527
  private readonly memo: ResolvedShellMemoOptions;
499
528
  private readonly handleMemos: VercelHandleMemos;
@@ -518,7 +547,7 @@ export class VercelCacheStore<
518
547
  this.waitUntil = options.waitUntil;
519
548
  this.defaults = options.defaults;
520
549
  this.keyGenerator = options.keyGenerator;
521
- this.version = options.version;
550
+ this.explicitVersion = options.version;
522
551
  this.maxItemBytes = options.maxItemBytes ?? VERCEL_MAX_ITEM_BYTES;
523
552
  this.maxShellEntryBytes = Math.floor((this.maxItemBytes * 3) / 4);
524
553
  this.memo = resolveShellMemoOptions(options.memo, {
@@ -1469,8 +1498,7 @@ export class VercelCacheStore<
1469
1498
  // cross-family one reads as corrupt. See the class doc for passing a wider
1470
1499
  // hash (sha256) when many keys are live.
1471
1500
  private toStoreKey(key: string, family: CacheFamily): string {
1472
- const versionPrefix = this.version ? `v/${this.version}/` : "";
1473
- return `${versionPrefix}rg:${family}:${key}`;
1501
+ return `${versionKeyPrefix(this.explicitVersion, FAMILY_VERSION[family])}rg:${family}:${key}`;
1474
1502
  }
1475
1503
 
1476
1504
  private async write(
@@ -172,6 +172,7 @@ export function ClientUrlsRoot({
172
172
  params: presented.params,
173
173
  pathname: presented.pathname,
174
174
  search: presented.search,
175
+ state: presented.state,
175
176
  },
176
177
  }
177
178
  : null,
@@ -17,6 +17,8 @@ export interface ClientUrlNavigationIntent {
17
17
  /** Absolute destination pathname (mount included) and search ("?..." or ""). */
18
18
  readonly pathname: string;
19
19
  readonly search: string;
20
+ /** `history.state` the navigation will push (OptimisticLocation.state). */
21
+ readonly state: unknown;
20
22
  /**
21
23
  * transition({ when }) gated this navigation off at the swap: the
22
24
  * destination presents urgently instead of in a transition lane.
@@ -37,6 +39,8 @@ export interface ClientUrlDestination {
37
39
  * params minus these are the mount's (include prefix) params.
38
40
  */
39
41
  readonly originLocalParamNames: readonly string[];
42
+ /** `history.state` the navigation will push (ClientUrlNavigationIntent.state). */
43
+ readonly state: unknown;
40
44
  }
41
45
 
42
46
  interface ActiveClientUrlGroup {
@@ -124,6 +128,8 @@ export function registerClientUrlGroup(
124
128
  export function beginClientUrlNavigation(
125
129
  targetUrl: URL,
126
130
  signal: AbortSignal,
131
+ /** Builds the entry state; called only when the group presents the target. */
132
+ buildState?: () => unknown,
127
133
  decideTransition?: (destination: ClientUrlDestination) => boolean,
128
134
  ): ClientUrlNavigationPresentation | null {
129
135
  const group = activeGroup;
@@ -149,6 +155,8 @@ export function beginClientUrlNavigation(
149
155
  : undefined;
150
156
  if (canonicalName && activeInterceptTargets.has(canonicalName)) return null;
151
157
 
158
+ const state = buildState?.();
159
+
152
160
  // A same-route intent never swaps (ClientUrlsRoot), so transition({ when })
153
161
  // decides at the canonical commit; a cross-route one decides here, at the
154
162
  // navigation's first presentation.
@@ -163,6 +171,7 @@ export function beginClientUrlNavigation(
163
171
  routeName: canonicalName,
164
172
  when: typeof when === "function" ? when : undefined,
165
173
  originLocalParamNames: Object.keys(originMatch?.params ?? {}),
174
+ state,
166
175
  });
167
176
  }
168
177
 
@@ -171,6 +180,7 @@ export function beginClientUrlNavigation(
171
180
  params: match.params,
172
181
  pathname: targetUrl.pathname,
173
182
  search: targetUrl.search,
183
+ state,
174
184
  transitionGatedOff,
175
185
  };
176
186
  group.intent = intent;
@@ -6,16 +6,22 @@ import { createContext, type Context } from "react";
6
6
  * Route identity of an optimistically rendered clientUrls() destination:
7
7
  * the values the local trie match produced for the URL the user navigated
8
8
  * to. Provided by ClientUrlsRoot around the optimistic branch ONLY, so
9
- * useParams / usePathname / useSearchParams inside that branch describe the
10
- * route being rendered while the same hooks in chrome outside it keep the
11
- * committed location until the canonical response commits (or redirects, in
12
- * which case the branch — and these values — are discarded).
9
+ * useParams / usePathname / useSearchParams / useLocationState inside that
10
+ * branch describe the route being rendered while the same hooks in chrome
11
+ * outside it keep the committed location until the canonical response commits
12
+ * (or redirects, in which case the branch — and these values — are discarded).
13
13
  */
14
14
  export interface OptimisticLocation {
15
15
  readonly params: Readonly<Record<string, string>>;
16
16
  readonly pathname: string;
17
17
  /** Search string including the leading "?" (or ""). */
18
18
  readonly search: string;
19
+ /**
20
+ * `history.state` the navigation will push: its `state` option, without
21
+ * what the server adds (that arrives with the canonical commit). History
22
+ * still holds the entry being left, so useLocationState reads this instead.
23
+ */
24
+ readonly state: unknown;
19
25
  }
20
26
 
21
27
  export const OptimisticLocationContext: Context<OptimisticLocation | null> =
@@ -88,9 +88,20 @@ async function resolveValues(values: unknown[]): Promise<unknown[]> {
88
88
  * A hung handle promise blocks the full render like any unresolved await (no
89
89
  * handle-specific timeout). Partial / action payloads keep streaming via
90
90
  * `handleStore.stream()`.
91
+ *
92
+ * `recordedOnly` is a PPR shell capture's render (full-payload.ts): the
93
+ * snapshot is cut down to the pushes the capture's record keeps
94
+ * (getDataForSegment's `excludeLoaderPushes`, the filter captureHandles
95
+ * writes the record with). A HIT restores that record and hydrates with it,
96
+ * so a push the record leaves out must not be in the prelude either: it
97
+ * rendered an element no HIT's hydration data matched (issue #1035). Which
98
+ * pushes a record keeps is the capture funnel's decision (shell-capture.ts
99
+ * deriveShellCaptureContext: today it leaves out a deferred loader push and
100
+ * one holding a masked promise), not this function's.
91
101
  */
92
102
  export async function* resolvedHandleStream(
93
103
  handleStore: HandleStore,
104
+ recordedOnly?: boolean,
94
105
  ): AsyncGenerator<HandleData, void, unknown> {
95
106
  // Drain stream() (NOT getData()) for the converged snapshot: consuming a
96
107
  // stream arms the store's late-push guard (LateHandlePushError for pushes
@@ -109,9 +120,29 @@ export async function* resolvedHandleStream(
109
120
  for await (const data of handleStore.stream("settled")) {
110
121
  snapshot = data;
111
122
  }
123
+ if (recordedOnly) snapshot = recordedHandleData(handleStore, snapshot);
112
124
  yield await resolveDeferredHandleValues(snapshot);
113
125
  }
114
126
 
127
+ /** `snapshot` without the pushes a record leaves out (resolvedHandleStream). */
128
+ function recordedHandleData(
129
+ handleStore: HandleStore,
130
+ snapshot: HandleData,
131
+ ): HandleData {
132
+ const segmentIds = new Set<string>();
133
+ for (const handleName in snapshot) {
134
+ for (const segmentId in snapshot[handleName]) segmentIds.add(segmentId);
135
+ }
136
+ const recorded: HandleData = {};
137
+ for (const segmentId of segmentIds) {
138
+ const kept = handleStore.getDataForSegment(segmentId, true);
139
+ for (const handleName in kept) {
140
+ (recorded[handleName] ??= {})[segmentId] = kept[handleName];
141
+ }
142
+ }
143
+ return recorded;
144
+ }
145
+
115
146
  /**
116
147
  * Resolve the deferred (Promise) values in ONE segment's handle bucket
117
148
  * (`handleName -> entries[]`). Same shallow + drop-rejections rule as
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { ShellCacheEntry } from "../cache/types.js";
24
+ import type { RouterVersions } from "../router-versions.js";
24
25
  import type { MatchResult } from "../types.js";
25
26
  import { MemorySegmentCacheStore } from "../cache/memory-segment-store.js";
26
27
  import {
@@ -105,13 +106,14 @@ export interface BuildShellCaptureOptions {
105
106
  /** Build-time env bindings (rango plugin buildEnv), if configured. */
106
107
  buildEnv?: unknown;
107
108
  /**
108
- * The MAIN build's version (the version plugin's value folded into the
109
- * shipped worker) — NOT the temp server's own version-plugin value. The
110
- * serve-side isValidShellHit gate compares entry.buildVersion against the
111
- * running worker's ctx.version; stamping the temp server's would make every
112
- * build entry an eternal MISS.
109
+ * The versions the SHIPPED build serves this router with (the build table,
110
+ * or the router's consumer-set version) — NOT the temp server's own
111
+ * version-plugin stamp. The serve-side isValidShellHit gate compares
112
+ * entry.buildVersion against the running worker's ctx.version, which is the
113
+ * document version; stamping the temp server's would make every build entry
114
+ * an eternal MISS.
113
115
  */
114
- buildVersion: string;
116
+ versions: RouterVersions;
115
117
  /**
116
118
  * The SSR half, composed by the plugin from the temp server's SSR
117
119
  * environment runner (react-dom/static prerender + Flight client), with the
@@ -192,7 +194,7 @@ async function attemptBuildCapture(
192
194
  cacheStore: new MemorySegmentCacheStore(),
193
195
  themeConfig: router.themeConfig ?? null,
194
196
  stateCookieName: router.resolvedStateCookieName,
195
- version: opts.buildVersion,
197
+ versions: opts.versions,
196
198
  });
197
199
  // Scope registry lookups (root-scope/search-schema) per router during the
198
200
  // bake, mirroring rsc/handler.ts on the request path (#762).
@@ -215,7 +217,7 @@ async function attemptBuildCapture(
215
217
 
216
218
  const descriptor: ShellCaptureDescriptor = {
217
219
  key: opts.key,
218
- buildVersion: opts.buildVersion,
220
+ buildVersion: opts.versions.document,
219
221
  ttl: opts.ttl,
220
222
  swr: opts.swr,
221
223
  tags: opts.tags,
@@ -448,7 +450,7 @@ async function runBuildCaptureFinal(
448
450
  // buildFullPayload reads only ctx.router.* and ctx.version.
449
451
  {
450
452
  router,
451
- version: opts.buildVersion,
453
+ version: opts.versions.document,
452
454
  } as unknown as HandlerContext<any>,
453
455
  url,
454
456
  derivedCtx,
@@ -38,6 +38,8 @@ const MODULE_LOAD_FAILURE = Symbol.for("rango.moduleLoadFailure");
38
38
  // LP1 in docs/internal/matching-and-lazy-discovery.md. VERSION comes from the
39
39
  // @rangojs/router:version virtual module which Vite invalidates on RSC module HMR.
40
40
  // When VERSION changes, this module re-evaluates and the cache is recreated empty.
41
+ // Dev module generation only: in production VERSION is constant per process, so
42
+ // the per-router cache versions play no part in this cache.
41
43
  const manifestModuleCache = new Map<string, Map<string, EntryData>>();
42
44
 
43
45
  /**
@@ -91,15 +91,14 @@
91
91
  * - Request object
92
92
  * - Action context (if POST)
93
93
  */
94
- import type { InternalHandlerContext, ResolvedSegment } from "../../types.js";
95
- import type { OwnedPushDelivery } from "../../cache/handle-snapshot.js";
96
- import type { EntryData } from "../../server/context.js";
94
+ import type { ResolvedSegment } from "../../types.js";
95
+ import { isPprEntry, type EntryData } from "../../server/context.js";
97
96
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
98
97
  import { getRouterContext, type RouterContext } from "../router-context.js";
99
98
  import { observeEvent } from "../instrument.js";
100
99
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
101
100
  import { treeHasStreaming } from "./segment-resolution.js";
102
- import { liveLaneLoaderIds } from "../segment-resolution/loader-cache.js";
101
+ import { loaderPins } from "../segment-resolution/loader-cache.js";
103
102
  import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
104
103
  import {
105
104
  _getRequestContext,
@@ -546,20 +545,14 @@ export function withCacheLookup<TEnv>(
546
545
  return;
547
546
  }
548
547
 
549
- // A record's loader-owned handle values claim their loader, so its own
550
- // cache() HIT does not replay them again (restoreHandles). A document
551
- // HIT tail restores a bake-lane owner's values as the prelude's, unclaimed:
552
- // they stand against any later push of that loader
553
- // (HandleStore.pushRestored). An owner the route runs on the live lane is
554
- // a hole, so its live run still replaces them, even inside a restored
555
- // loader's body.
556
- const ownedPushes: OwnedPushDelivery = {
557
- claim: (ctx.handlerContext as InternalHandlerContext)._claimLoaderPushes,
558
- };
559
- if (tailMarker) {
560
- ownedPushes.liveLane = liveLaneLoaderIds(ctx.entries);
561
- ownedPushes.restore = tailMarker.docTail === true;
562
- }
548
+ // Only a ppr route's records hold loader pushes, and only its shell pins
549
+ // loaders (CachedEntryData.handleOwners, the loader seed): any other
550
+ // route's record restores as a plain replay.
551
+ const leaf = ctx.entries[ctx.entries.length - 1];
552
+ const ownedPushes =
553
+ leaf !== undefined && isPprEntry(leaf)
554
+ ? () => loaderPins(ctx.entries, pipelineReqCtx)
555
+ : undefined;
563
556
  const explicitLookup = await ctx.cacheScope.lookupRouteDetailed(
564
557
  ctx.pathname,
565
558
  ctx.matched.params,
@@ -38,12 +38,31 @@ export interface ResolveNavigationDeps {
38
38
  * never produce a partial match, so the replay path must decide
39
39
  * `no-navigation-context` BEFORE spending shell-store reads — the two
40
40
  * predicates drifting apart would misattribute the bypass again.
41
+ *
42
+ * `requestOrigin` is the origin pathname matching uses (`url.origin`), which
43
+ * may not be `request.url`'s. `X-RSC-Router-Client-Path` is returned as sent.
44
+ * A Referer counts only when it resolves against that origin and stays there.
45
+ * A cross-origin Referer is dropped: resolveNavigation matches only the
46
+ * pathname, so a foreign URL would be treated as a route on this app. A value
47
+ * that does not parse is no context either.
41
48
  */
42
- export function getNavigationContextHeader(request: Request): string | null {
43
- return (
44
- requestHeaders(request).get("X-RSC-Router-Client-Path") ||
45
- requestHeaders(request).get("Referer")
46
- );
49
+ export function getNavigationContextHeader(
50
+ request: Request,
51
+ requestOrigin: string,
52
+ ): string | null {
53
+ const headers = requestHeaders(request);
54
+ const clientPath = headers.get("X-RSC-Router-Client-Path");
55
+ if (clientPath) return clientPath;
56
+
57
+ const referer = headers.get("Referer");
58
+ if (!referer) return null;
59
+
60
+ try {
61
+ if (new URL(referer, requestOrigin).origin !== requestOrigin) return null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ return referer;
47
66
  }
48
67
 
49
68
  /**
@@ -84,7 +103,7 @@ export async function resolveNavigation(
84
103
  const clientSegmentIds =
85
104
  url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
86
105
  const stale = url.searchParams.get("_rsc_stale") === "true";
87
- const previousUrl = getNavigationContextHeader(request);
106
+ const previousUrl = getNavigationContextHeader(request, url.origin);
88
107
  const interceptSourceUrl = getInterceptSourceHeader(request);
89
108
  const isHmr = !!requestHeaders(request).get("X-RSC-HMR");
90
109
 
@@ -170,6 +170,21 @@ export async function classifyRequest<TEnv = any>(
170
170
  const willFullRender =
171
171
  !isAction && !isLoaderFetch && !isPartialReq && !isPeRender;
172
172
 
173
+ // Before the version check: each router has its own version, so a tab of
174
+ // another app always carries one this router does not have. That request is
175
+ // an app switch, not a stale client.
176
+ const clientRouterId = url.searchParams.get("_rsc_rid");
177
+ if (
178
+ clientRouterId &&
179
+ clientRouterId !== deps.routerId &&
180
+ url.searchParams.has("_rsc_partial")
181
+ ) {
182
+ return {
183
+ mode: "app-switch",
184
+ reloadUrl: stripInternalParams(url).toString(),
185
+ };
186
+ }
187
+
173
188
  const clientVersion = url.searchParams.get("_rsc_v");
174
189
  if (
175
190
  deps.routerVersion &&
@@ -195,18 +210,6 @@ export async function classifyRequest<TEnv = any>(
195
210
  };
196
211
  }
197
212
 
198
- const clientRouterId = url.searchParams.get("_rsc_rid");
199
- if (
200
- clientRouterId &&
201
- clientRouterId !== deps.routerId &&
202
- url.searchParams.has("_rsc_partial")
203
- ) {
204
- return {
205
- mode: "app-switch",
206
- reloadUrl: stripInternalParams(url).toString(),
207
- };
208
- }
209
-
210
213
  const result = await resolveRoute<TEnv>(pathname, {
211
214
  findMatch: deps.findMatch,
212
215
  lite: true,
@@ -457,7 +457,8 @@ export interface RangoInternal<
457
457
  readonly nonce?: NonceProvider<TEnv>;
458
458
 
459
459
  /**
460
- * RSC version string
460
+ * The consumer-set version (createRouter({ version })), used for both of the
461
+ * router's versions. Undefined when the router uses its build versions.
461
462
  */
462
463
  readonly version?: string;
463
464