@rangojs/router 0.22.1 → 0.23.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 (179) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +842 -308
  63. package/package.json +1 -1
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/request-context.ts +21 -1
  143. package/src/server.ts +4 -0
  144. package/src/testing/action-form.ts +2 -11
  145. package/src/testing/e2e/page-helpers.ts +3 -1
  146. package/src/testing/flight.entry.ts +1 -0
  147. package/src/testing/index.ts +6 -3
  148. package/src/testing/internal/action-id.ts +16 -1
  149. package/src/testing/internal/client-urls-projection.ts +70 -0
  150. package/src/testing/internal/test-action.ts +25 -0
  151. package/src/testing/run-loader.ts +2 -1
  152. package/src/testing/run-revalidate.ts +142 -0
  153. package/src/testing/run-transition-when.ts +7 -8
  154. package/src/testing/serve-shell-request.ts +116 -6
  155. package/src/testing/vitest.ts +67 -1
  156. package/src/types/boundaries.ts +12 -0
  157. package/src/types/handler-context.ts +14 -0
  158. package/src/types/loader-types.ts +30 -0
  159. package/src/types/segments.ts +26 -4
  160. package/src/urls/path-helper-types.ts +27 -5
  161. package/src/urls/type-extraction.ts +30 -10
  162. package/src/use-loader.tsx +18 -13
  163. package/src/vite/discovery/client-urls-projection.ts +20 -21
  164. package/src/vite/discovery/discover-routers.ts +94 -26
  165. package/src/vite/discovery/state.ts +20 -4
  166. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  167. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  168. package/src/vite/plugins/hoist/literal.ts +417 -0
  169. package/src/vite/plugins/hoist/scope.ts +370 -0
  170. package/src/vite/plugins/hoist/source.ts +84 -0
  171. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  172. package/src/vite/rango.ts +2 -0
  173. package/src/vite/router-discovery.ts +93 -32
  174. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  175. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  176. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  177. package/src/client-urls/revalidate-chain.ts +0 -83
  178. package/src/client-urls/revalidation-protocol.ts +0 -56
  179. package/src/testing/run-client-revalidate.ts +0 -92
@@ -15,6 +15,9 @@ declare global {
15
15
  * L2 (KV): Global, persistent, ~50ms reads. Auto-warms cold colos.
16
16
  *
17
17
  * Read flow: L1 hit → serve | L1 miss → L2 hit → serve + promote to L1 | both miss → render
18
+ * With `kvFallThrough`, an L1 copy a tag marker rejects reads as
19
+ * an L1 miss: L2 is checked against the same markers (see get(),
20
+ * "Tag invalidation"). Off by default: the rejected copy is a miss.
18
21
  * Write flow: L1 write + L2 write (both via waitUntil)
19
22
  *
20
23
  * Features:
@@ -388,7 +391,7 @@ type OpenedShellFrame =
388
391
  | { status: "corrupt"; error: Error; head?: undefined }
389
392
  | { status: "expired"; head: ShellFrameHead }
390
393
  | { status: "timeout"; head?: undefined }
391
- | { status: "invalidated"; head?: undefined };
394
+ | { status: "invalidated"; head: ShellFrameHead };
392
395
 
393
396
  /** The `[CFCacheStore][shell]` trace's timings, from a read's stats. */
394
397
  function debugTimings(
@@ -531,6 +534,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
531
534
  private readonly tagPurge?: (cacheTags: string[]) => Promise<void>;
532
535
  private readonly tagInvalidationTtl?: number;
533
536
  private readonly tagCacheTtl: number;
537
+ private readonly kvFallThrough: boolean;
534
538
 
535
539
  constructor(options: CFCacheStoreOptions<TEnv>) {
536
540
  if (!options.ctx) {
@@ -605,6 +609,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
605
609
  this.keyGenerator = options.keyGenerator;
606
610
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
607
611
  this.kv = options.kv;
612
+ this.kvFallThrough = options.kvFallThrough === true && !!options.kv;
608
613
  // Without KV, isTagsInvalidatedSince has no durable history — it answers
609
614
  // from the per-request mask at best. Runtime shells tolerate that (purge
610
615
  // eviction + ttl/swr bound the staleness), but an immutable TAGGED
@@ -1210,16 +1215,17 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1210
1215
  return this.kvGetSegment(key, { suppressRevalidate: true });
1211
1216
  }
1212
1217
 
1213
- // Tag invalidation: an entry whose tags were invalidated after it was
1214
- // cached is treated as a miss, so the next render re-populates it. We
1215
- // return null (re-render locally) rather than falling through to KV. In
1216
- // the common case the L1 entry and its KV twin were written together with
1217
- // the same taggedAt, so kvGetSegment's own tag check would miss too and a
1218
- // fall-through is pure cost. The tiers CAN diverge -- another colo may have
1219
- // already re-rendered and written a fresher KV envelope -- in which case a
1220
- // fall-through could serve that copy instead of re-rendering here.
1221
- // Capturing that cross-colo optimization is a deferred follow-up, not a
1222
- // correctness gap: this colo's next read after its own re-render self-heals.
1218
+ // Tag invalidation: a copy whose tags were invalidated after it was
1219
+ // cached is never served. Without `kvFallThrough` it is a miss and this
1220
+ // colo re-renders. With it, it reads as an L1 miss, because the tiers can
1221
+ // diverge: another colo may have written KV after the marker (its own
1222
+ // re-render, or a router.prerender() warm after updateTag()), and this
1223
+ // colo then serves that entry instead of rendering it again.
1224
+ // kvGetSegment checks the KV entry against the same markers (this
1225
+ // request's memo: no second marker read) and promotes a valid one over
1226
+ // this copy. When KV still holds the twin written with this copy, the
1227
+ // read costs one KV get and misses as before. The request that
1228
+ // invalidated the tag itself keeps the plain miss (declinesKvFallThrough).
1223
1229
  const tagInfo = this.readTagInfo(response.headers);
1224
1230
  // Measure the marker-resolution tail (memo -> L1 marker cache -> KV) only
1225
1231
  // when debug is on, so the hot path pays nothing. It is the serial read
@@ -1241,7 +1247,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1241
1247
  matchMs,
1242
1248
  markerMs,
1243
1249
  });
1244
- return null;
1250
+ return this.declinesKvFallThrough(tagInfo.tags, tagInfo.taggedAt)
1251
+ ? null
1252
+ : this.kvGetSegment(key);
1245
1253
  }
1246
1254
 
1247
1255
  // Read status headers
@@ -1524,7 +1532,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1524
1532
  return this.kvGetResponse(key);
1525
1533
  }
1526
1534
 
1527
- // Tag invalidation check (treat invalidated entry as a miss).
1535
+ // Tag invalidation check: a rejected copy reads as an L1 miss (see get()).
1528
1536
  const tagInfo = this.readTagInfo(response.headers);
1529
1537
  if (
1530
1538
  await this.isL1Invalidated(
@@ -1533,7 +1541,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1533
1541
  response.headers,
1534
1542
  )
1535
1543
  ) {
1536
- return null;
1544
+ return this.declinesKvFallThrough(tagInfo.tags, tagInfo.taggedAt)
1545
+ ? null
1546
+ : this.kvGetResponse(key);
1537
1547
  }
1538
1548
 
1539
1549
  // Check staleness
@@ -1801,8 +1811,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1801
1811
  return this.kvGetItem(key, { suppressRevalidate: true });
1802
1812
  }
1803
1813
 
1804
- // Tag invalidation check (treat invalidated entry as a miss). Measure the
1805
- // marker-resolution tail only under debug (see get()).
1814
+ // Tag invalidation check: a rejected copy reads as an L1 miss (see
1815
+ // get()). Measure the marker-resolution tail only under debug.
1806
1816
  const tagInfo = this.readTagInfo(response.headers);
1807
1817
  const markerStart = this.debug ? Date.now() : 0;
1808
1818
  const invalidated = await this.isL1Invalidated(
@@ -1821,7 +1831,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1821
1831
  matchMs,
1822
1832
  markerMs,
1823
1833
  });
1824
- return null;
1834
+ return this.declinesKvFallThrough(tagInfo.tags, tagInfo.taggedAt)
1835
+ ? null
1836
+ : this.kvGetItem(key);
1825
1837
  }
1826
1838
 
1827
1839
  const staleAt = Number(
@@ -2233,7 +2245,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2233
2245
  tier: "l1",
2234
2246
  stats,
2235
2247
  });
2236
- return null;
2248
+ // As get(): KV may hold a capture from after the marker.
2249
+ return this.declinesKvFallThrough(opened.head.t, opened.head.ta)
2250
+ ? null
2251
+ : kvFallback("invalidated");
2237
2252
  }
2238
2253
  const read = this.shellDocumentRead(
2239
2254
  key,
@@ -2325,7 +2340,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2325
2340
  * dropped and read from the store (SWR recapture scheduling and each mode's
2326
2341
  * invalidation semantics stay with the store read). That read keeps the
2327
2342
  * caller's `tagHints`: cfTagHints may have evicted the key while the memo
2328
- * still holds it.
2343
+ * still holds it. A shell a tag marker rejects is dropped and read from
2344
+ * the store too, which may hold a capture from after the marker (see
2345
+ * get(), "Tag invalidation"), unless the request's own mask rejected it
2346
+ * (declinesKvFallThrough).
2329
2347
  */
2330
2348
  private async readMemoizedShell(
2331
2349
  key: string,
@@ -2360,7 +2378,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2360
2378
  tier: "memo",
2361
2379
  stats,
2362
2380
  });
2363
- return null;
2381
+ return this.declinesKvFallThrough(head.t, head.ta)
2382
+ ? null
2383
+ : this.readShellDocumentWithin(key, options, snapshotBudget);
2364
2384
  }
2365
2385
  if (stats) stats.memo = { hit: true, bytes: cfShellMemo.size };
2366
2386
  this.recordShellHit(key, head.t, stats);
@@ -2472,7 +2492,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2472
2492
  }
2473
2493
  if (invalidated) {
2474
2494
  reader.cancel();
2475
- return { status: "invalidated" };
2495
+ return { status: "invalidated", head: value.head };
2476
2496
  }
2477
2497
  return value;
2478
2498
  }
@@ -3341,6 +3361,24 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3341
3361
  return false;
3342
3362
  }
3343
3363
 
3364
+ /**
3365
+ * Whether a rejected L1 copy skips the KV read: always unless the
3366
+ * `kvFallThrough` option is on, and with it on when this request's own
3367
+ * updateTag()/revalidateTag() (its mask, request-tag-mask.ts) rejects an
3368
+ * entry tagged at `taggedAt`. KV holds a newer entry only if something
3369
+ * rendered after this request's invalidation, while the request re-renders
3370
+ * every entry it masked, so the read would add one KV get per masked entry
3371
+ * to that re-render.
3372
+ * @internal
3373
+ */
3374
+ private declinesKvFallThrough(
3375
+ tags: string[] | undefined,
3376
+ taggedAt: number | undefined,
3377
+ ): boolean {
3378
+ if (!this.kvFallThrough) return true;
3379
+ return maskedForRequest(_getRequestContext(), this, tags, taggedAt ?? 0);
3380
+ }
3381
+
3344
3382
  /**
3345
3383
  * Synthetic Cache API request for a tag's L1-cached invalidation marker.
3346
3384
  * Unversioned for the same reason as tagMarkerKey; the cache namespace
@@ -66,8 +66,11 @@ export interface CFCacheReadDebugEvent {
66
66
  * - kv-timeout: the L2/KV read budget fired (read abandoned, NOT a genuine
67
67
  * absence -- distinct from kv-miss so a degradation signal is separable)
68
68
  * - tag-invalidated: a live L1/KV entry whose cache tags were invalidated
69
- * after it was written -- treated as a miss so the next render re-populates
70
- * it (the tag-invalidation read path, distinct from a plain miss)
69
+ * after it was written -- never served (the tag-invalidation read path,
70
+ * distinct from a plain miss). With `kvFallThrough` on, an L1 copy then
71
+ * reads L2/KV, so a kv-* event follows unless the request invalidated the
72
+ * tag itself; from L2/KV it is a miss and the next render re-populates
73
+ * the entry
71
74
  * - error: the read threw
72
75
  */
73
76
  outcome:
@@ -383,6 +386,25 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
383
386
  */
384
387
  kvReadTimeoutMs?: number;
385
388
 
389
+ /**
390
+ * After `updateTag()`/`revalidateTag()`, a location whose own L1 copy a tag
391
+ * marker rejects reads KV next, and serves the entry another location (or a
392
+ * `router.prerender()` warm) wrote after the invalidation, instead of
393
+ * rendering again. Covers `cache()` records, `"use cache"` results, cached
394
+ * responses and `ppr` shells. The request that invalidated the tag still
395
+ * re-renders its own entries without the read.
396
+ *
397
+ * Cost: one KV get per rejected copy per location after each invalidation
398
+ * (150 ms added wait, measured in #1069 locally at 50 ms per KV read on a
399
+ * document with a shell, a record and one `"use cache"` result, every copy
400
+ * rejected; not measured on a deployed worker), paid even when KV holds only
401
+ * the old twin. Worth it where re-rendering costs more than a KV read
402
+ * (several locations, expensive renders). Needs `kv`; ignored without it.
403
+ *
404
+ * Defaults to false: a rejected L1 copy is a miss and the location renders.
405
+ */
406
+ kvFallThrough?: boolean;
407
+
386
408
  /**
387
409
  * Per-isolate memos ({@link StoreMemoOptions}), one set per isolate and
388
410
  * shared by every CFCacheStore in it. `shellMs` (default 2000) and
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { requestHeaders } from "../server/request-headers.js";
17
+ import { FILL_PARAM } from "../prefetch-markers.js";
17
18
  import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
18
19
  import { hasPerClientSignal } from "../browser/cookie-name.js";
19
20
  import {
@@ -122,7 +123,10 @@ function parseCacheControl(header: string | null): CacheDirectives | null {
122
123
  */
123
124
  function shouldCacheResponse(
124
125
  response: Response,
125
- requestCtx?: Pick<RequestContext, "_payloadVisitorTheme">,
126
+ requestCtx?: Pick<
127
+ RequestContext,
128
+ "_payloadVisitorTheme" | "_payloadDeferred"
129
+ >,
126
130
  ): CacheDirectives | null {
127
131
  // Only cache successful responses
128
132
  if (response.status !== 200) {
@@ -146,6 +150,15 @@ function shouldCacheResponse(
146
150
  return null;
147
151
  }
148
152
 
153
+ // prefetch: false. The payload carries deferred units: it answers one
154
+ // page's prefetch and nothing else. parseCacheControl refuses the
155
+ // Cache-Control it is sent with (rsc-rendering.ts NOT_REUSED), but a
156
+ // middleware can replace that after next(), and a navigation answered
157
+ // with this body would show a fallback no fill ever replaces.
158
+ if (requestCtx?._payloadDeferred) {
159
+ return null;
160
+ }
161
+
149
162
  const cacheControl = response.headers.get("Cache-Control");
150
163
  return parseCacheControl(cacheControl);
151
164
  }
@@ -337,6 +350,14 @@ export function createDocumentCacheMiddleware<TEnv = any>(
337
350
  return next();
338
351
  }
339
352
 
353
+ // Skip fill requests (prefetch: false): a fill is the second half of one
354
+ // adoption, rendered against what that client holds. Never served from a
355
+ // stored body and never stored. Asked of the raw URL like the two skips
356
+ // above: the request context is not read yet.
357
+ if (rawUrl.searchParams.has(FILL_PARAM)) {
358
+ return next();
359
+ }
360
+
340
361
  // Skip configured paths
341
362
  if (skipPaths.some((path) => url.pathname.startsWith(path))) {
342
363
  return next();
@@ -3,9 +3,9 @@ import { validateUserRouteName } from "../route-name.js";
3
3
  import { buildRouteTrie } from "../router/route-trie-builder.js";
4
4
  import { tryTrieMatch } from "../router/trie-matching.js";
5
5
  import type { LoaderDefinition } from "../types.js";
6
+ import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
6
7
  import type {
7
8
  ClientPathOptions,
8
- ClientRevalidateFn,
9
9
  ClientTransitionConfig,
10
10
  ClientUrlBuilder,
11
11
  ClientUrlHelpers,
@@ -137,13 +137,14 @@ interface TransitionItem extends ItemBase {
137
137
  interface LoaderItem extends ItemBase {
138
138
  readonly type: "loader";
139
139
  readonly definition: LoaderDefinition<any, any>;
140
- readonly revalidate: readonly ClientRevalidateFn[];
140
+ readonly revalidate: ClientUrlLoaderRecord["revalidate"];
141
141
  readonly ssr?: false;
142
+ readonly prefetch?: false;
142
143
  }
143
144
 
144
145
  interface RevalidateItem extends ItemBase {
145
146
  readonly type: "revalidate";
146
- readonly fn: ClientRevalidateFn;
147
+ readonly fn: ClientUrlLoaderRecord["revalidate"][number];
147
148
  }
148
149
 
149
150
  interface LoadingItem extends ItemBase {
@@ -386,11 +387,22 @@ function createHelpers(): ClientUrlHelpers {
386
387
  "clientUrls() loader() stream was replaced: use loader(Def, { ssr: false }) — the same knob as loading(fallback, { ssr: false }) — to await the loader before first flush on document requests.",
387
388
  );
388
389
  }
390
+ // Inline, not errors.ts assertBooleanOption: this module is in the
391
+ // client router chunk, where the helper costs 20 B gzip.
389
392
  if (options?.ssr !== undefined && typeof options.ssr !== "boolean") {
390
393
  throw new Error(
391
394
  `clientUrls() loader() ssr must be a boolean (got ${JSON.stringify(options.ssr)}). Omit it (or pass true) to stream on every render.`,
392
395
  );
393
396
  }
397
+ if (
398
+ process.env.NODE_ENV !== "production" &&
399
+ options?.prefetch !== undefined &&
400
+ typeof options.prefetch !== "boolean"
401
+ ) {
402
+ throw new Error(
403
+ `clientUrls() loader() prefetch must be a boolean (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
404
+ );
405
+ }
394
406
  const items = use ? runUse(use, "loader use") : [];
395
407
  rejectItems(items, new Set(["revalidate"]), "loader");
396
408
  return toPublicItem({
@@ -404,11 +416,14 @@ function createHelpers(): ClientUrlHelpers {
404
416
  )
405
417
  .map((item) => item.fn),
406
418
  ...(options?.ssr === false ? { ssr: false as const } : {}),
419
+ ...(options?.prefetch === false ? { prefetch: false as const } : {}),
407
420
  });
408
421
  };
409
422
 
410
423
  const revalidate: ClientUrlHelpers["revalidate"] = (fn) => {
411
- if (typeof fn !== "function") {
424
+ // The build leaves a marker in place of the predicate it moved to the
425
+ // server (vite/plugins/client-urls-revalidate-hoist.ts).
426
+ if (typeof fn !== "function" && !isHoistedRevalidateRef(fn)) {
412
427
  throw new Error("clientUrls() revalidate() expects a predicate function");
413
428
  }
414
429
  return toPublicItem({
@@ -563,6 +578,7 @@ function applyConfig(
563
578
  loader: item.definition,
564
579
  revalidate: Object.freeze([...item.revalidate]),
565
580
  ...(item.ssr === false ? { ssr: false as const } : {}),
581
+ ...(item.prefetch === false ? { prefetch: false as const } : {}),
566
582
  }),
567
583
  );
568
584
  } else if (item.type === "loading") {
@@ -675,6 +691,8 @@ function collectIntercepts(
675
691
  let loading: ReactNode | undefined;
676
692
  for (const useItem of item.use) {
677
693
  if (useItem.type === "loader") {
694
+ // `prefetch` is not carried: an intercept prefetch defers nothing,
695
+ // so the flag is accepted and ignored, as on a urls() intercept().
678
696
  loaders.push(
679
697
  Object.freeze({
680
698
  loader: useItem.definition,
@@ -762,8 +780,6 @@ export function clientUrls<const TItems extends ClientUrlItems>(
762
780
  }
763
781
 
764
782
  export type {
765
- ClientRevalidateArgs,
766
- ClientRevalidateFn,
767
783
  ClientTransitionConfig,
768
784
  ClientUrlBuilder,
769
785
  ClientUrlHelpers,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The value the build leaves in place of a clientUrls() `revalidate()`
3
+ * predicate in the browser and SSR copies of a "use client" module
4
+ * (vite/plugins/client-urls-revalidate-hoist.ts): the predicate itself moves
5
+ * to a server module, registered under the same content key
6
+ * (revalidate-registry.ts). The projection carries the key; the materialized
7
+ * loader stub calls the registered predicate.
8
+ */
9
+
10
+ export const HOISTED_REVALIDATE_KEY = "$$rangoRevalidate";
11
+
12
+ export interface HoistedRevalidateRef {
13
+ readonly $$rangoRevalidate: string;
14
+ }
15
+
16
+ export function isHoistedRevalidateRef(
17
+ value: unknown,
18
+ ): value is HoistedRevalidateRef {
19
+ return (
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ typeof (value as Partial<HoistedRevalidateRef>)[HOISTED_REVALIDATE_KEY] ===
23
+ "string"
24
+ );
25
+ }
@@ -1,12 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import { startTransition } from "react";
4
- import { makeIsAction } from "../router/is-action.js";
5
- import {
6
- lockedClientDefault,
7
- runClientRevalidateChain,
8
- } from "./revalidate-chain.js";
9
- import { encodeClientRevalidationDecisions } from "./revalidation-protocol.js";
10
4
  import type { ClientUrlPatterns } from "./types.js";
11
5
  import type { TransitionWhenFn } from "../types/segments.js";
12
6
 
@@ -210,96 +204,6 @@ export function beginClientUrlNavigation(
210
204
  };
211
205
  }
212
206
 
213
- /**
214
- * Run the held clientUrls route's per-loader revalidate() predicates and
215
- * encode their decisions for the request header. Predicates are CLIENT code
216
- * (declared in the "use client" definition module); only decisions cross the
217
- * wire — the synthesized per-loader revalidate() on every materialized stub
218
- * (server-projection.ts) reads them back by loader $$id.
219
- *
220
- * Decisions exist only for the loaders the client currently HOLDS: the local
221
- * match of currentUrl. They matter when the server would re-evaluate those
222
- * held loader segments — same-route param/search navs, actions, and stale
223
- * restores. Cross-route targets render new segments unconditionally, so a
224
- * decision would be ignored; we still compute against the current route since
225
- * only its held segments are addressable.
226
- *
227
- * Returns null when no group is active, the current location is not a client
228
- * route, or no predicate produced a decision that differs from the locked
229
- * default — the server then applies defaults, which is also the behavior for
230
- * requests that cannot carry decisions (no-JS, PE, prefetch, document loads).
231
- */
232
- export function collectClientRevalidationDecisions(options: {
233
- currentUrl: URL;
234
- nextUrl: URL;
235
- /**
236
- * True only when the decisions ride the action POST itself — the one
237
- * request the server evaluates with actionContext (locked default true).
238
- * Action-triggered refetch GETs (partial-update terminals) pass false:
239
- * the server gives those navigation defaults, and the delta gate below
240
- * must diff against the default the SERVER will use, or a force decision
241
- * on the refetch would be silently swallowed as "equals default".
242
- */
243
- actionRequest: boolean;
244
- /** Action TRUTH for the predicates' isAction() matcher; may be true on
245
- * refetch GETs where actionRequest is false. */
246
- isAction: boolean;
247
- actionId?: string;
248
- stale: boolean;
249
- }): string | null {
250
- const group = activeGroup;
251
- if (!group) return null;
252
-
253
- const { currentUrl, nextUrl, actionRequest, isAction, actionId, stale } =
254
- options;
255
- const currentLocal = stripMountPrefix(currentUrl.pathname, group.mount);
256
- if (currentLocal === null) return null;
257
- const currentMatch = group.definition.match(currentLocal);
258
- if (!currentMatch || currentMatch.redirectTo) return null;
259
- const record = group.definition.routes.find(
260
- (candidate) => candidate.id === currentMatch.routeKey,
261
- );
262
- if (!record) return null;
263
-
264
- const nextLocal = stripMountPrefix(nextUrl.pathname, group.mount);
265
- const nextMatch =
266
- nextLocal === null ? null : group.definition.match(nextLocal);
267
- const nextParams = nextMatch?.params ?? {};
268
-
269
- const defaultShouldRevalidate = lockedClientDefault({
270
- actionRequest,
271
- currentParams: currentMatch.params,
272
- nextParams,
273
- currentUrl,
274
- nextUrl,
275
- });
276
-
277
- const baseArgs = {
278
- currentUrl,
279
- nextUrl,
280
- currentParams: currentMatch.params,
281
- nextParams,
282
- stale,
283
- isAction: makeIsAction(actionId, isAction),
284
- ...(actionId !== undefined ? { actionId } : {}),
285
- };
286
- const skip: string[] = [];
287
- const force: string[] = [];
288
- for (const { loader, revalidate } of record.loaders) {
289
- if (revalidate.length === 0) continue;
290
- const decision = runClientRevalidateChain(
291
- revalidate,
292
- baseArgs,
293
- defaultShouldRevalidate,
294
- `loader "${loader.$$id}"`,
295
- );
296
- if (decision === defaultShouldRevalidate) continue;
297
- (decision ? force : skip).push(loader.$$id);
298
- }
299
-
300
- return encodeClientRevalidationDecisions({ skip, force });
301
- }
302
-
303
207
  export function clearClientUrlNavigationRegistry(): void {
304
208
  activeGroup = null;
305
209
  activeInterceptTargets = new Set();
@@ -0,0 +1,30 @@
1
+ import type { ShouldRevalidateFn } from "../types.js";
2
+
3
+ /**
4
+ * Hoisted clientUrls() `revalidate()` predicates by content key. The
5
+ * generated `<file>?rango-revalidate` module of a group fills it when the
6
+ * group's module loads in the RSC realm
7
+ * (vite/plugins/client-urls-revalidate-hoist.ts); materialized loader stubs
8
+ * read it (server-projection.ts). A key hashes the file and the predicate's
9
+ * syntax, so a re-registration under one key is the same predicate.
10
+ *
11
+ * Server-only build output, not router state: no router hook reads it
12
+ * (AGENTS.md Hard rule 8).
13
+ */
14
+ const revalidators = new Map<string, ShouldRevalidateFn<any, any>>();
15
+
16
+ export function registerClientUrlRevalidators(
17
+ fns: Readonly<Record<string, ShouldRevalidateFn<any, any>>>,
18
+ ): void {
19
+ for (const [key, fn] of Object.entries(fns)) revalidators.set(key, fn);
20
+ }
21
+
22
+ export function getClientUrlRevalidator(
23
+ key: string,
24
+ ): ShouldRevalidateFn<any, any> | undefined {
25
+ return revalidators.get(key);
26
+ }
27
+
28
+ export function clearClientUrlRevalidators(): void {
29
+ revalidators.clear();
30
+ }