@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
@@ -735,15 +735,14 @@ export class CacheScope {
735
735
  * @param pathname - URL pathname for cache key generation
736
736
  * @param params - Route params for cache key generation
737
737
  * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
738
- * @param ownedPushes - How restoreHandles delivers the record's
739
- * loader-owned values (withCacheLookup: the handler context's
740
- * _claimLoaderPushes, and on a document HIT tail the owners it restores)
738
+ * @param ownedPushes - What the record is to each loader's handle pushes
739
+ * (withCacheLookup: loader-cache.ts loaderPins), called on a hit
741
740
  */
742
741
  async lookupRoute(
743
742
  pathname: string,
744
743
  params: Record<string, string>,
745
744
  isIntercept?: boolean,
746
- ownedPushes?: OwnedPushDelivery,
745
+ ownedPushes?: () => OwnedPushDelivery,
747
746
  ): Promise<{
748
747
  segments: ResolvedSegment[];
749
748
  shouldRevalidate: boolean;
@@ -779,7 +778,7 @@ export class CacheScope {
779
778
  pathname: string,
780
779
  params: Record<string, string>,
781
780
  isIntercept?: boolean,
782
- ownedPushes?: OwnedPushDelivery,
781
+ ownedPushes?: () => OwnedPushDelivery,
783
782
  ): Promise<CacheRouteLookupOutcome> {
784
783
  if (!this.enabled) return { status: "bypass" };
785
784
  if (!this.conditionAllows("read")) return { status: "bypass" };
@@ -870,11 +869,23 @@ export class CacheScope {
870
869
  cached.tags,
871
870
  );
872
871
 
872
+ // Before the handle replay: the implicit doc scope's marker arms the
873
+ // loader pins of a navigation replay here (matchPartialWithPprReplay),
874
+ // and `ownedPushes` reads them to decide which loader-owned values
875
+ // stand. The segments decoded, so the record serves from here on.
876
+ await this.onHit?.();
877
+
878
+ const handleStore = _getRequestContext()?._handleStore;
879
+ // With or without a handles blob: a pinned loader whose capture pushed
880
+ // nothing has no copy in the record, and its run on this request must
881
+ // still add no settled push (HandleStore.setRecordAuthority).
882
+ const owned = handleStore ? ownedPushes?.() : undefined;
883
+ if (owned) handleStore?.setRecordAuthority(owned);
884
+
873
885
  // Replay handle data. An empty string means the route pushed no handles —
874
886
  // skip the decode entirely (the common case). Otherwise decode the
875
887
  // Flight-encoded blob; a decode failure skips handle restore but keeps the
876
888
  // valid cached segments.
877
- const handleStore = _getRequestContext()?._handleStore;
878
889
  if (handleStore && cached.handles) {
879
890
  const handlesRecord = await decodeHandles(cached.handles);
880
891
  if (handlesRecord) {
@@ -888,7 +899,7 @@ export class CacheScope {
888
899
  handlesRecord,
889
900
  handleStore,
890
901
  cached.handleOwners,
891
- ownedPushes,
902
+ owned,
892
903
  );
893
904
  }
894
905
  }
@@ -905,7 +916,6 @@ export class CacheScope {
905
916
  if (ambientContext)
906
917
  this.noteRecordWindow(ambientContext, cached.expiresAt, false);
907
918
 
908
- await this.onHit?.();
909
919
  return { status: "hit", result: { segments, shouldRevalidate } };
910
920
  } catch (error) {
911
921
  // Covers a store.get() failure AND a throwing consumer key()/keyGenerator
@@ -1,5 +1,3 @@
1
- /// <reference path="../../vite/plugins/version.d.ts" />
2
-
3
1
  // Extend CacheStorage with Cloudflare's default cache property
4
2
  declare global {
5
3
  interface CacheStorage {
@@ -50,7 +48,10 @@ import {
50
48
  type RequestContext,
51
49
  } from "../../server/request-context.js";
52
50
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
53
- import { VERSION } from "@rangojs/router:version";
51
+ import {
52
+ versionKeyPrefix,
53
+ type KeyVersion,
54
+ } from "../../server/build-version-table.js";
54
55
  import {
55
56
  isPerClientSignalHeader,
56
57
  stripPerClientSignals,
@@ -314,10 +315,9 @@ interface KVItemEnvelope {
314
315
 
315
316
  /**
316
317
  * Key namespace of the shell family. `shell2:` holds the prelude-first frame
317
- * (cf-shell-frame.ts); `shell:` held the JSON envelope. Every deploy retires
318
- * the old entries anyway (buildVersion gate); the new namespace also keeps
319
- * new code from reporting an old body as corrupt and a rollback from parsing
320
- * a frame. Old keys age out by their TTL.
318
+ * (cf-shell-frame.ts); `shell:` held the JSON envelope. The separate
319
+ * namespace keeps new code from reporting an old body as corrupt and a
320
+ * rollback from parsing a frame. Old keys age out by their TTL.
321
321
  */
322
322
  const SHELL_KEY_PREFIX = "shell2:";
323
323
 
@@ -494,7 +494,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
494
494
  private readonly namespace?: string;
495
495
  private readonly explicitBaseUrl?: string;
496
496
  private readonly waitUntil?: (fn: () => Promise<void>) => void;
497
- private readonly version?: string;
497
+ /** The `version` option: used for every versioned family when set. */
498
+ private readonly explicitVersion?: string;
498
499
  private readonly edgeLookupTimeoutMs: number;
499
500
  private readonly edgeReadTimeoutMs: number;
500
501
  private readonly kvReadTimeoutMs: number;
@@ -540,7 +541,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
540
541
  // the internal host. Only the explicit override can be captured eagerly.
541
542
  this.explicitBaseUrl = options.baseUrl;
542
543
  this.defaults = options.defaults;
543
- this.version = options.version ?? VERSION;
544
+ this.explicitVersion = options.version;
544
545
  // Coalesce only finite numbers to the override; a non-finite value (NaN from
545
546
  // `Number(env.UNSET)`, or Infinity) would otherwise sail past `?? DEFAULT`
546
547
  // (which only replaces null/undefined) into setTimeout, where NaN/Infinity
@@ -1134,7 +1135,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1134
1135
  if (this.isReservedSegmentKey(key, "cache-read")) return null;
1135
1136
  try {
1136
1137
  const cache = await this.getCache();
1137
- const request = this.keyToRequest(key);
1138
+ const request = this.keyToRequest(key, "data");
1138
1139
  const matchStart = Date.now();
1139
1140
  const {
1140
1141
  response,
@@ -1381,7 +1382,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1381
1382
  if (this.skipUncacheableTagSet(data.tags)) return;
1382
1383
  try {
1383
1384
  const cache = await this.getCache();
1384
- const request = this.keyToRequest(key);
1385
+ const request = this.keyToRequest(key, "data");
1385
1386
 
1386
1387
  // Extended TTL covers SWR window
1387
1388
  const swrWindow = resolveSwrWindow(swr, this.defaults);
@@ -1457,11 +1458,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1457
1458
  if (this.isReservedSegmentKey(key, "cache-delete")) return false;
1458
1459
  try {
1459
1460
  const cache = await this.getCache();
1460
- const result = await cache.delete(this.keyToRequest(key));
1461
+ const result = await cache.delete(this.keyToRequest(key, "data"));
1461
1462
 
1462
1463
  // L2: delete from KV
1463
1464
  if (this.kv && this.waitUntil) {
1464
- const kvKey = await this.toKVKey(key);
1465
+ const kvKey = await this.toKVKey(key, "data");
1465
1466
  this.waitUntil(() =>
1466
1467
  reportingAsync(
1467
1468
  () => this.kv!.delete(kvKey),
@@ -1492,7 +1493,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1492
1493
  ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
1493
1494
  try {
1494
1495
  const cache = await this.getCache();
1495
- const request = this.keyToRequest(`doc:${key}`);
1496
+ const request = this.keyToRequest(`doc:${key}`, "document");
1496
1497
  // The document path is outside the debug surface (op is only get/getItem),
1497
1498
  // so the match-timeout flag is not surfaced as an event here -- though
1498
1499
  // matchWithTimeout still warns on a slow match. A miss or timeout falls
@@ -1633,7 +1634,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1633
1634
  if (this.skipUncacheableTagSet(tags)) return;
1634
1635
  try {
1635
1636
  const cache = await this.getCache();
1636
- const request = this.keyToRequest(`doc:${key}`);
1637
+ const request = this.keyToRequest(`doc:${key}`, "document");
1637
1638
 
1638
1639
  // Extended TTL covers SWR window
1639
1640
  const swrWindow = resolveSwrWindow(swr, this.defaults);
@@ -1747,7 +1748,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1747
1748
  async getItem(key: string): Promise<CacheItemResult | null> {
1748
1749
  try {
1749
1750
  const cache = await this.getCache();
1750
- const request = this.keyToRequest(`fn:${key}`);
1751
+ const request = this.keyToRequest(`fn:${key}`, "data");
1751
1752
  const matchStart = Date.now();
1752
1753
  const {
1753
1754
  response,
@@ -1932,7 +1933,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1932
1933
  if (this.skipUncacheableTagSet(options?.tags)) return;
1933
1934
  try {
1934
1935
  const cache = await this.getCache();
1935
- const request = this.keyToRequest(`fn:${key}`);
1936
+ const request = this.keyToRequest(`fn:${key}`, "data");
1936
1937
 
1937
1938
  const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
1938
1939
  const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
@@ -1984,7 +1985,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1984
1985
  // JSON.stringify(KVItemEnvelope); field names differ from L1 so we assemble
1985
1986
  // from the pre-escaped value/handles pieces rather than re-stringifying.
1986
1987
  if (this.kv && this.waitUntil && totalTtl >= 60) {
1987
- const kvKey = await this.toKVKey(`fn:${key}`);
1988
+ const kvKey = await this.toKVKey(`fn:${key}`, "data");
1988
1989
  const expiresAt = staleAt + swrWindow * 1000;
1989
1990
  let envelopeJson = `{"v":${valueJson}`;
1990
1991
  if (handlesJson !== undefined) envelopeJson += `,"h":${handlesJson}`;
@@ -2154,7 +2155,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2154
2155
  }
2155
2156
  try {
2156
2157
  const cache = await this.getCache();
2157
- const request = this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`);
2158
+ const request = this.shellRequest(key);
2158
2159
  const matchStartedAt = stats ? performance.now() : 0;
2159
2160
  const {
2160
2161
  response,
@@ -2297,14 +2298,17 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2297
2298
  }
2298
2299
  }
2299
2300
 
2300
- /** The isolate marker memo's key for a tag: markers are per namespace and version. */
2301
+ /**
2302
+ * The isolate marker memo's key for a tag: markers are per namespace, and
2303
+ * shared by every version (tagMarkerKey).
2304
+ */
2301
2305
  private markerMemoKey(tag: string): string {
2302
- return `${this.namespace ?? ""}\u0000${this.version ?? ""}\u0000${tag}`;
2306
+ return `${this.namespace ?? ""}\u0000${tag}`;
2303
2307
  }
2304
2308
 
2305
- /** The memo key: one per namespace, build version, base URL, and shell key. */
2309
+ /** The memo key: one per namespace, document version, base URL, and shell key. */
2306
2310
  private shellMemoKey(key: string): string {
2307
- return `${this.namespace ?? ""}\u0000${this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`).url}`;
2311
+ return `${this.namespace ?? ""}\u0000${this.shellRequest(key).url}`;
2308
2312
  }
2309
2313
 
2310
2314
  /**
@@ -2571,15 +2575,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2571
2575
  const evict = async (): Promise<void> => {
2572
2576
  const cache = await this.getCache();
2573
2577
  await reportingAsync(
2574
- () => cache.delete(this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`)),
2578
+ () => cache.delete(this.shellRequest(key)),
2575
2579
  "cache-delete",
2576
2580
  "[CFCacheStore] getShell: evict corrupt L1",
2577
2581
  );
2578
2582
  if (this.kv) {
2579
- await this.evictKvKey(
2580
- await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`),
2581
- "getShell",
2582
- );
2583
+ await this.evictKvKey(await this.shellKVKey(key), "getShell");
2583
2584
  }
2584
2585
  };
2585
2586
  if (this.waitUntil) this.waitUntil(evict);
@@ -2641,9 +2642,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2641
2642
  Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
2642
2643
 
2643
2644
  const writeKv = !!this.kv && retentionTtl > 0;
2644
- const kvKey = writeKv
2645
- ? await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`)
2646
- : null;
2645
+ const kvKey = writeKv ? await this.shellKVKey(key) : null;
2647
2646
 
2648
2647
  const write = (async (): Promise<"stored" | "invalidated" | void> => {
2649
2648
  if (
@@ -2684,7 +2683,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2684
2683
  try {
2685
2684
  const cache = await this.getCache();
2686
2685
  await cache.put(
2687
- this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
2686
+ this.shellRequest(key),
2688
2687
  this.shellFrameResponse(frame, head),
2689
2688
  );
2690
2689
  this.debugShell(key, "l1-stored", { expiresAt: head.e });
@@ -2781,7 +2780,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2781
2780
  // One kvReadTimeoutMs budget covers opening the value and reading its
2782
2781
  // head and prelude; the remainder after the open bounds the frame read.
2783
2782
  const deadline = Date.now() + this.kvReadTimeoutMs;
2784
- const kvKey = await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`);
2783
+ const kvKey = await this.shellKVKey(key);
2785
2784
  // A transient read REJECTION propagates to the catch below (reported
2786
2785
  // cache-read, the entry left intact); only a malformed frame on a body
2787
2786
  // that WAS read is corruption that evicts.
@@ -2884,7 +2883,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2884
2883
  if (Date.now() > head.e) return;
2885
2884
  const cache = await this.getCache();
2886
2885
  await cache.put(
2887
- this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
2886
+ this.shellRequest(key),
2888
2887
  this.shellFrameResponse(
2889
2888
  encodeShellFrame(head, prelude, snapshotBytes),
2890
2889
  head,
@@ -2905,18 +2904,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2905
2904
  // Key Helpers
2906
2905
  // ============================================================================
2907
2906
 
2907
+ private shellRequest(key: string): Request {
2908
+ return this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`, "document");
2909
+ }
2910
+
2911
+ private shellKVKey(key: string): Promise<string> {
2912
+ return this.toKVKey(`${SHELL_KEY_PREFIX}${key}`, "document");
2913
+ }
2914
+
2908
2915
  /**
2909
2916
  * Convert string key to Request object for CF Cache API.
2910
- * Includes version in URL if specified (for cache invalidation on code changes).
2917
+ *
2918
+ * `family` picks the version in the URL path, so a version change misses:
2919
+ * `"data"` for segment entries and `fn:` items (cached RSC data, valid while
2920
+ * the router's server code is unchanged), `"document"` for `doc:` responses
2921
+ * and `shell2:` shells (their bytes carry asset URLs and the handler
2922
+ * version), `null` for tag markers, which apply to every version.
2911
2923
  * @internal
2912
2924
  */
2913
- private keyToRequest(key: string): Request {
2925
+ private keyToRequest(key: string, family: KeyVersion): Request {
2914
2926
  const encodedKey = encodeURIComponent(key);
2915
- // Include version in URL path to invalidate cache when version changes
2916
- const versionPath = this.version ? `v/${this.version}/` : "";
2917
- return new Request(`${this.resolveBaseUrl()}${versionPath}${encodedKey}`, {
2918
- method: "GET",
2919
- });
2927
+ return new Request(
2928
+ `${this.resolveBaseUrl()}${versionKeyPrefix(this.explicitVersion, family)}${encodedKey}`,
2929
+ { method: "GET" },
2930
+ );
2920
2931
  }
2921
2932
 
2922
2933
  /**
@@ -2937,9 +2948,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2937
2948
  * and delete paths agree on the stored key.
2938
2949
  * @internal
2939
2950
  */
2940
- private async toKVKey(key: string): Promise<string> {
2941
- const versionPath = this.version ? `v/${this.version}/` : "";
2942
- const composed = `${versionPath}${key}`;
2951
+ private async toKVKey(key: string, family: KeyVersion): Promise<string> {
2952
+ const composed = `${versionKeyPrefix(this.explicitVersion, family)}${key}`;
2943
2953
  if (kvKeyByteLength(composed) <= KV_MAX_KEY_BYTES) return composed;
2944
2954
  const prefix = truncateToBytes(composed, KV_KEY_PRESERVED_PREFIX_BYTES);
2945
2955
  return `${prefix}~${await kvKeyDigest(composed)}`;
@@ -2973,7 +2983,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2973
2983
  * @internal
2974
2984
  */
2975
2985
  private toDocKVKey(key: string): Promise<string> {
2976
- return this.toKVKey(`h/${this.docKVHost()}/doc:${key}`);
2986
+ return this.toKVKey(`h/${this.docKVHost()}/doc:${key}`, "document");
2977
2987
  }
2978
2988
 
2979
2989
  /**
@@ -3087,9 +3097,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3087
3097
  // Tag Invalidation (single-store: markers live in this.kv)
3088
3098
  // ============================================================================
3089
3099
 
3090
- /** KV key for a tag's invalidation marker. */
3100
+ /**
3101
+ * KV key for a tag's invalidation marker. Unversioned: a content-hash
3102
+ * version can come back (deploy A, deploy B, roll back to A), and a marker
3103
+ * written while B was live has to be visible to A's entries. The staleness
3104
+ * check compares the marker time with the entry's taggedAt and nothing else
3105
+ * (isGloballyInvalidated), so one marker per tag serves every version.
3106
+ */
3091
3107
  private tagMarkerKey(tag: string): Promise<string> {
3092
- return this.toKVKey(`${TAG_MARKER_PREFIX}${tag}`);
3108
+ return this.toKVKey(`${TAG_MARKER_PREFIX}${tag}`, null);
3093
3109
  }
3094
3110
 
3095
3111
  /**
@@ -3320,9 +3336,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3320
3336
  return false;
3321
3337
  }
3322
3338
 
3323
- /** Synthetic Cache API request for a tag's L1-cached invalidation marker. */
3339
+ /**
3340
+ * Synthetic Cache API request for a tag's L1-cached invalidation marker.
3341
+ * Unversioned for the same reason as tagMarkerKey; the cache namespace
3342
+ * (getCache) still scopes it.
3343
+ */
3324
3344
  private tagMarkerRequest(tag: string): Request {
3325
- return this.keyToRequest(`${TAG_MARKER_CACHE_PREFIX}${tag}`);
3345
+ return this.keyToRequest(`${TAG_MARKER_CACHE_PREFIX}${tag}`, null);
3326
3346
  }
3327
3347
 
3328
3348
  /**
@@ -3974,7 +3994,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3974
3994
  if (!this.kv) return null;
3975
3995
 
3976
3996
  try {
3977
- const kvKey = await this.toKVKey(key);
3997
+ const kvKey = await this.toKVKey(key, "data");
3978
3998
  const { value: envelope, timedOut } =
3979
3999
  await this.kvGetOrEvict<KVSegmentEnvelope>(
3980
4000
  kvKey,
@@ -4076,7 +4096,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
4076
4096
  this.waitUntil(() =>
4077
4097
  reportingAsync(
4078
4098
  async () =>
4079
- this.kv!.put(await this.toKVKey(key), envelopeJson, {
4099
+ this.kv!.put(await this.toKVKey(key, "data"), envelopeJson, {
4080
4100
  expirationTtl: totalTtl,
4081
4101
  }),
4082
4102
  "cache-write",
@@ -4101,7 +4121,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
4101
4121
  Math.floor((envelope.e - now) / 1000),
4102
4122
  );
4103
4123
  const cache = await this.getCache();
4104
- const request = this.keyToRequest(key);
4124
+ const request = this.keyToRequest(key, "data");
4105
4125
 
4106
4126
  const response = new Response(JSON.stringify(envelope.d), {
4107
4127
  headers: {
@@ -4137,7 +4157,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
4137
4157
  if (!this.kv) return null;
4138
4158
 
4139
4159
  try {
4140
- const kvKey = await this.toKVKey(`fn:${key}`);
4160
+ const kvKey = await this.toKVKey(`fn:${key}`, "data");
4141
4161
  const { value: envelope, timedOut } =
4142
4162
  await this.kvGetOrEvict<KVItemEnvelope>(
4143
4163
  kvKey,
@@ -4221,7 +4241,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
4221
4241
  Math.floor((envelope.e - now) / 1000),
4222
4242
  );
4223
4243
  const cache = await this.getCache();
4224
- const request = this.keyToRequest(`fn:${key}`);
4244
+ const request = this.keyToRequest(`fn:${key}`, "data");
4225
4245
 
4226
4246
  const body = JSON.stringify({
4227
4247
  value: envelope.v,
@@ -4355,7 +4375,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
4355
4375
  Math.floor((envelope.e - now) / 1000),
4356
4376
  );
4357
4377
  const cache = await this.getCache();
4358
- const request = this.keyToRequest(`doc:${key}`);
4378
+ const request = this.keyToRequest(`doc:${key}`, "document");
4359
4379
 
4360
4380
  const headers = new Headers(envelope.hd);
4361
4381
  const originalCacheControl = headers.get("Cache-Control");
@@ -320,11 +320,19 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
320
320
  tagCacheTtl?: number;
321
321
 
322
322
  /**
323
- * Cache version string override. When this changes, all cached entries are
324
- * effectively invalidated (new keys won't match old entries).
323
+ * Cache version string override. When set, this exact value prefixes every
324
+ * versioned key the store writes (`v/{version}/...`), for cached data and
325
+ * stored HTML alike; changing it makes every earlier entry unreachable.
325
326
  *
326
- * Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
327
- * Only set this if you need a custom versioning strategy.
327
+ * Leave it unset for the default: the store keys with the versions of the
328
+ * router serving the request. Segment entries and `"use cache"` items use
329
+ * the router's data version; document responses and PPR shells use its
330
+ * document version (see `createRouter({ version })`). Those are computed
331
+ * from the router's built code, so a deploy that does not change the router
332
+ * keeps its entries.
333
+ *
334
+ * Tag invalidation markers are never versioned: an invalidation applies to
335
+ * the entries of every version, including one that comes back in a rollback.
328
336
  */
329
337
  version?: string;
330
338
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { replayLoaderPush } from "./handle-capture.js";
10
10
  import type { ResolvedSegment } from "../types.js";
11
- import type { HandleStore } from "../server/handle-store.js";
11
+ import type { HandleStore, RecordAuthority } from "../server/handle-store.js";
12
12
  import type { HandleOwners, SegmentHandleData } from "./types.js";
13
13
  // segment-codec eagerly pulls @vitejs/plugin-rsc (a virtual: module unresolvable
14
14
  // in plain node/vitest). It is imported LAZILY inside the two async encode/decode
@@ -134,28 +134,13 @@ export function captureHandleOwners(
134
134
  return owners;
135
135
  }
136
136
 
137
- /** How restoreHandles delivers a record's loader-owned values. */
138
- export interface OwnedPushDelivery {
139
- /**
140
- * Asked once per owner delivered through pushReplayed
141
- * (setupLoaderAccess _claimLoaderPushes); an owner it refuses is skipped.
142
- */
143
- claim?: (loaderId: string) => boolean;
144
- /**
145
- * A PPR shell's record (capture, document HIT tail, navigation replay): the
146
- * loaders the route runs on the live lane (liveLaneLoaderIds), marked on
147
- * the store (HandleStore.markLiveLane). A hole's values are replayed
148
- * unclaimed, so its own cache() HIT redelivers its entry's pushes in their
149
- * place (HandleStore.redeliverReplays), and its run ending without a push
150
- * drops them (HandleStore.settleLoaderRun).
151
- */
152
- liveLane?: ReadonlySet<string>;
153
- /**
154
- * A document HIT tail: every owner outside `liveLane` goes through
155
- * pushRestored and stands, unclaimed.
156
- */
157
- restore?: boolean;
158
- }
137
+ /**
138
+ * What the record being restored is to each loader's handle pushes on this
139
+ * request (HandleStore RecordAuthority). Built by loaderPins (loader-cache.ts)
140
+ * over the pins resolveLoaderData serves a loader's value from, so a value
141
+ * and its record copies get one answer.
142
+ */
143
+ export type OwnedPushDelivery = (loaderId: string) => RecordAuthority;
159
144
 
160
145
  /**
161
146
  * Restore handle data from a cached snapshot into the handle store.
@@ -163,24 +148,12 @@ export interface OwnedPushDelivery {
163
148
  * cache() record owns its segments' arrays, so it REPLACES them.
164
149
  *
165
150
  * A segment with `owners` is replaced with empty arrays and re-pushed in
166
- * recorded order, an owned value through pushReplayed: if the owning loader
167
- * runs (a client navigation replaying a ppr route's cache() record runs its
168
- * `ssr: false` loaders), its live pushes replace the recorded ones in place
169
- * instead of appending a second copy. `owned.claim` is asked once per
170
- * owner, so the loader's own cache() HIT does not replay its pushes a second
171
- * time (loader-cache.ts replayLoaderHandles). A record without owners
172
- * restores as a plain replay.
173
- *
174
- * `owned.restore`: a document HIT tail restoring the shell's doc record
175
- * (withCacheLookup). A bake-lane owner's values are the prelude's, so they go
176
- * through pushRestored and stand: a run of that loader on the HIT (a
177
- * promise-carrying `ssr: false` loader, one whose record asks for a run)
178
- * reads the store, and its settled pushes are dropped, not swapped in.
179
- * Nothing is claimed, so the loader's own cache() HIT still replays the
180
- * pushes the record could not keep (the thenable ones pushRestored lets
181
- * through). A loader the route also runs on the live lane is a hole: its
182
- * values keep pushReplayed, unclaimed, and its live run replaces them
183
- * (#936), even inside a restored loader's body (`owned.liveLane`).
151
+ * recorded order. A loader-owned value stands (pushRestored) where `owned`
152
+ * says the record supplies that loader's value too ("pin", "copies"), and is
153
+ * a placeholder (pushPlaceholder) everywhere else: without `owned`, every
154
+ * owner. Neither claims its loader, so the loader's own cache() HIT still
155
+ * delivers (the thenable pushes a restored copy lets through, or its entry's
156
+ * pushes in a placeholder's place).
184
157
  */
185
158
  export function restoreHandles(
186
159
  handles: Record<string, SegmentHandleData>,
@@ -188,8 +161,6 @@ export function restoreHandles(
188
161
  owners?: HandleOwners,
189
162
  owned?: OwnedPushDelivery,
190
163
  ): void {
191
- let delivers: Map<string, boolean> | undefined;
192
- if (owned?.liveLane) handleStore.markLiveLane(owned.liveLane);
193
164
  for (const [segId, segHandles] of Object.entries(handles)) {
194
165
  if (Object.keys(segHandles).length === 0) continue;
195
166
  const segOwners = owners?.[segId];
@@ -208,23 +179,11 @@ export function restoreHandles(
208
179
  handleStore.push(handleName, segId, values[i]);
209
180
  continue;
210
181
  }
211
- const hole = owned?.liveLane?.has(owner) === true;
212
- if (owned?.restore && !hole) {
182
+ const authority = owned?.(owner);
183
+ if (authority === "pin" || authority === "copies") {
213
184
  handleStore.pushRestored(handleName, segId, values[i], owner);
214
- continue;
215
- }
216
- if (hole) {
217
- handleStore.pushReplayed(handleName, segId, values[i], owner);
218
- continue;
219
- }
220
- delivers ??= new Map();
221
- let deliver = delivers.get(owner);
222
- if (deliver === undefined) {
223
- deliver = owned?.claim ? owned.claim(owner) : true;
224
- delivers.set(owner, deliver);
225
- }
226
- if (deliver) {
227
- handleStore.pushReplayed(handleName, segId, values[i], owner);
185
+ } else {
186
+ handleStore.pushPlaceholder(handleName, segId, values[i], owner);
228
187
  }
229
188
  }
230
189
  }
@@ -238,43 +197,66 @@ export function restoreHandles(
238
197
  * restoreHandles would wipe those. `segmentId` redirects every value to one
239
198
  * segment (the caller's).
240
199
  *
241
- * Owner-keyed groups (useCacheRecordKey, `${seq}:${loaderId}`) are a loader's
242
- * pushes, which reach the page once per request: `claim` (setupLoaderAccess
243
- * _claimLoaderPushes) skips a loader that already ran or was replayed in this
244
- * request, and a claimed loader that runs later replaces its replayed values
245
- * (HandleStore.pushReplayed). `${seq}:` groups are the function's own pushes.
246
- * A key without ":" is a segment id (records written before owner keys) and
247
- * replays unchanged. A loader's own cache() replays through loader-cache.ts
248
- * replayLoaderHandles.
200
+ * The replay of every cached unit that records by owner: a "use cache"
201
+ * function (useCacheRecordKey) and a loader's own cache() (loader-cache.ts
202
+ * recordOwnerKey, with `unitLoader` the cached loader).
203
+ *
204
+ * A `${seq}:${loaderId}` group is that loader's pushes, which reach the page
205
+ * once per request: `claim` (setupLoaderAccess _claimLoaderPushes) is asked
206
+ * once per loader and skips one that already ran or was replayed in this
207
+ * request; a claimed loader that runs later replaces its replayed values
208
+ * (HandleStore.pushReplayed). A group without an owner (`${seq}:`, or a key
209
+ * without ":" from a record written before owner keys, which is a segment
210
+ * id) is the unit's own: `unitLoader`'s, or no loader's for a function,
211
+ * whose pushes are plain.
212
+ *
213
+ * The claimed loaders' placeholders give way to this delivery
214
+ * (HandleStore.replacePlaceholders), `unitLoader`'s even when the entry
215
+ * recorded no push for it: the entry stands for that loader's run.
216
+ *
217
+ * No `claim` means the caller's pushes are diverted off the page (a stale
218
+ * refresh: cache-runtime.ts refreshView, setupLoaderAccess
219
+ * _runLoaderIsolated): every group is delivered, into the diverting capture,
220
+ * and the page's placeholders stay, or the page would lose the push.
249
221
  */
250
222
  export function appendHandles(
251
223
  handles: Record<string, SegmentHandleData>,
252
224
  handleStore: HandleStore,
253
- segmentId?: string,
254
- claim?: (loaderId: string) => boolean,
225
+ segmentId: string | undefined,
226
+ claim: ((loaderId: string) => boolean) | undefined,
227
+ unitLoader?: string,
255
228
  ): void {
256
- let claims: Map<string, boolean> | undefined;
257
- for (const [key, segHandles] of Object.entries(handles)) {
229
+ const ownerOf = (key: string): string | undefined => {
258
230
  const colon = key.indexOf(":");
259
- const owner = colon < 0 ? "" : key.slice(colon + 1);
260
- if (owner) {
261
- claims ??= new Map();
262
- let deliver = claims.get(owner);
263
- if (deliver === undefined) {
264
- deliver = claim ? claim(owner) : true;
265
- claims.set(owner, deliver);
266
- }
267
- if (!deliver) continue;
231
+ return (colon < 0 ? "" : key.slice(colon + 1)) || unitLoader;
232
+ };
233
+ const claims = new Map<string, boolean>();
234
+ const ask = (owner: string | undefined): void => {
235
+ if (owner !== undefined && !claims.has(owner)) {
236
+ claims.set(owner, claim ? claim(owner) : true);
268
237
  }
269
- const target = segmentId ?? key;
270
- for (const [handleName, values] of Object.entries(segHandles)) {
271
- for (const value of values) {
272
- if (owner) {
273
- replayLoaderPush(handleStore, handleName, target, value, owner);
274
- } else {
275
- handleStore.push(handleName, target, value);
238
+ };
239
+ ask(unitLoader);
240
+ for (const key in handles) ask(ownerOf(key));
241
+ const deliver = (): void => {
242
+ for (const [key, segHandles] of Object.entries(handles)) {
243
+ const owner = ownerOf(key);
244
+ if (owner !== undefined && !claims.get(owner)) continue;
245
+ const target = segmentId ?? key;
246
+ for (const [handleName, values] of Object.entries(segHandles)) {
247
+ for (const value of values) {
248
+ if (owner !== undefined) {
249
+ replayLoaderPush(handleStore, handleName, target, value, owner);
250
+ } else {
251
+ handleStore.push(handleName, target, value);
252
+ }
276
253
  }
277
254
  }
278
255
  }
256
+ };
257
+ const granted: string[] = [];
258
+ if (claim) {
259
+ for (const [owner, given] of claims) if (given) granted.push(owner);
279
260
  }
261
+ handleStore.replacePlaceholders(granted, deliver);
280
262
  }