@rangojs/router 0.24.4 → 0.25.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 (267) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/rango.js +363 -39
  3. package/dist/testing/vitest.js +37 -4
  4. package/dist/types/browser/event-controller.d.ts +24 -5
  5. package/dist/types/browser/intercept-utils.d.ts +5 -2
  6. package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
  7. package/dist/types/browser/navigation-transaction.d.ts +5 -5
  8. package/dist/types/browser/partial-update.d.ts +21 -13
  9. package/dist/types/browser/prefetch/cache.d.ts +8 -0
  10. package/dist/types/browser/rango-marker.d.ts +7 -0
  11. package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
  12. package/dist/types/browser/react/use-handle.d.ts +20 -0
  13. package/dist/types/browser/react/use-href.d.ts +6 -0
  14. package/dist/types/browser/react/use-router.d.ts +6 -1
  15. package/dist/types/browser/response-adapter.d.ts +5 -3
  16. package/dist/types/browser/scroll-restoration.d.ts +9 -3
  17. package/dist/types/browser/segment-reconciler.d.ts +10 -13
  18. package/dist/types/browser/types.d.ts +38 -8
  19. package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
  20. package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
  21. package/dist/types/cache/types.d.ts +8 -0
  22. package/dist/types/client-urls/client-urls.d.ts +6 -2
  23. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  24. package/dist/types/client-urls/server-projection.d.ts +9 -5
  25. package/dist/types/client-urls/types.d.ts +29 -16
  26. package/dist/types/client.d.ts +2 -2
  27. package/dist/types/client.rsc.d.ts +2 -2
  28. package/dist/types/decode-loader-results.d.ts +28 -7
  29. package/dist/types/href-client.d.ts +3 -1
  30. package/dist/types/index.rsc.d.ts +1 -1
  31. package/dist/types/loader-redirect.d.ts +2 -3
  32. package/dist/types/outlet-context.d.ts +7 -6
  33. package/dist/types/prefetch-markers.d.ts +14 -5
  34. package/dist/types/route-content-wrapper.d.ts +17 -14
  35. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  36. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  37. package/dist/types/route-definition/rewrite.d.ts +48 -5
  38. package/dist/types/route-types.d.ts +2 -2
  39. package/dist/types/router/handler-context.d.ts +14 -2
  40. package/dist/types/router/intercept-resolution.d.ts +19 -46
  41. package/dist/types/router/lazy-includes.d.ts +7 -0
  42. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  43. package/dist/types/router/middleware.d.ts +0 -16
  44. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  45. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  46. package/dist/types/router/revalidation.d.ts +8 -1
  47. package/dist/types/router/router-context.d.ts +4 -16
  48. package/dist/types/router/router-interfaces.d.ts +0 -6
  49. package/dist/types/router/router-options.d.ts +4 -7
  50. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  51. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  52. package/dist/types/router/segment-resolution.d.ts +1 -1
  53. package/dist/types/router/segment-wrappers.d.ts +3 -5
  54. package/dist/types/router/substitute-pattern-params.d.ts +3 -2
  55. package/dist/types/router/tracing.d.ts +2 -2
  56. package/dist/types/rsc/full-payload.d.ts +14 -2
  57. package/dist/types/rsc/helpers.d.ts +8 -0
  58. package/dist/types/rsc/manifest-init.d.ts +20 -0
  59. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  60. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  61. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  62. package/dist/types/rsc/server-action.d.ts +6 -0
  63. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  64. package/dist/types/rsc/shell-capture.d.ts +7 -0
  65. package/dist/types/rsc/types.d.ts +5 -0
  66. package/dist/types/segment-system.d.ts +15 -0
  67. package/dist/types/server/context.d.ts +64 -4
  68. package/dist/types/server/request-context.d.ts +41 -4
  69. package/dist/types/server/request-headers.d.ts +12 -0
  70. package/dist/types/server/rewrite-trail.d.ts +8 -1
  71. package/dist/types/testing/e2e/index.d.ts +1 -1
  72. package/dist/types/testing/flight.entry.d.ts +1 -1
  73. package/dist/types/testing/index.d.ts +1 -1
  74. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  75. package/dist/types/testing/render-route.d.ts +21 -11
  76. package/dist/types/testing/run-loader.d.ts +7 -0
  77. package/dist/types/testing/run-middleware.d.ts +7 -0
  78. package/dist/types/testing/run-revalidate.d.ts +5 -4
  79. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  80. package/dist/types/testing/shell-status.d.ts +17 -0
  81. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  82. package/dist/types/testing/vitest.d.ts +10 -1
  83. package/dist/types/thenable-status.d.ts +0 -6
  84. package/dist/types/types/handler-context.d.ts +12 -1
  85. package/dist/types/types/loader-types.d.ts +4 -3
  86. package/dist/types/types/route-entry.d.ts +2 -1
  87. package/dist/types/urls/path-helper-types.d.ts +4 -2
  88. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  89. package/dist/types/vite/discovery/state.d.ts +20 -0
  90. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  91. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  92. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  93. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  94. package/dist/vite/index.js +6816 -6547
  95. package/package.json +1 -1
  96. package/skills/app-structure/SKILL.md +255 -0
  97. package/skills/app-structure/content-site.md +333 -0
  98. package/skills/app-structure/e-commerce.md +435 -0
  99. package/skills/app-structure/large-app.md +885 -0
  100. package/skills/app-structure/multi-app.md +280 -0
  101. package/skills/breadcrumbs/SKILL.md +6 -1
  102. package/skills/caching/SKILL.md +3 -1
  103. package/skills/catalog.json +12 -0
  104. package/skills/client-urls/SKILL.md +185 -32
  105. package/skills/composability/SKILL.md +10 -8
  106. package/skills/handler-use/SKILL.md +48 -9
  107. package/skills/hooks/handle-and-actions.md +104 -0
  108. package/skills/hooks/navigation.md +24 -10
  109. package/skills/hooks/urls.md +1 -1
  110. package/skills/i18n/SKILL.md +2 -1
  111. package/skills/intercept/SKILL.md +60 -29
  112. package/skills/layout/SKILL.md +6 -3
  113. package/skills/links/SKILL.md +151 -17
  114. package/skills/loader/SKILL.md +32 -10
  115. package/skills/middleware/SKILL.md +111 -15
  116. package/skills/observability/SKILL.md +11 -0
  117. package/skills/parallel/SKILL.md +5 -5
  118. package/skills/ppr/SKILL.md +43 -8
  119. package/skills/prerender/SKILL.md +1 -2
  120. package/skills/rango/SKILL.md +3 -0
  121. package/skills/response-routes/SKILL.md +4 -1
  122. package/skills/scripts/SKILL.md +12 -1
  123. package/skills/server-actions/SKILL.md +60 -20
  124. package/skills/testing/SKILL.md +1 -0
  125. package/skills/testing/client-components.md +13 -10
  126. package/skills/testing/loader.md +25 -24
  127. package/skills/testing/middleware.md +11 -11
  128. package/skills/testing/response-routes.md +4 -1
  129. package/skills/testing/serve-shell-request.md +85 -21
  130. package/skills/ux-architecture/SKILL.md +980 -0
  131. package/skills/view-transitions/SKILL.md +43 -10
  132. package/src/__augment-tests__/augmented.check.ts +28 -0
  133. package/src/browser/event-controller.ts +44 -18
  134. package/src/browser/intercept-utils.ts +3 -2
  135. package/src/browser/merge-segment-loaders.ts +0 -85
  136. package/src/browser/navigation-bridge.ts +112 -84
  137. package/src/browser/navigation-client.ts +20 -15
  138. package/src/browser/navigation-store.ts +4 -2
  139. package/src/browser/navigation-transaction.ts +9 -8
  140. package/src/browser/partial-update.ts +90 -32
  141. package/src/browser/prefetch/cache.ts +8 -0
  142. package/src/browser/prefetch/fetch.ts +13 -3
  143. package/src/browser/rango-marker.ts +13 -0
  144. package/src/browser/react/NavigationProvider.tsx +25 -7
  145. package/src/browser/react/filter-segment-order.ts +13 -0
  146. package/src/browser/react/use-action.ts +9 -15
  147. package/src/browser/react/use-handle.ts +63 -71
  148. package/src/browser/react/use-href.tsx +24 -5
  149. package/src/browser/react/use-link-status.ts +1 -1
  150. package/src/browser/react/use-navigation.ts +30 -40
  151. package/src/browser/react/use-router.ts +37 -6
  152. package/src/browser/react/use-segments.ts +8 -12
  153. package/src/browser/response-adapter.ts +11 -5
  154. package/src/browser/rsc-router.tsx +3 -1
  155. package/src/browser/scroll-restoration.ts +34 -20
  156. package/src/browser/segment-reconciler.ts +11 -29
  157. package/src/browser/server-action-bridge.ts +52 -16
  158. package/src/browser/types.ts +40 -8
  159. package/src/build/generate-manifest.ts +12 -1
  160. package/src/build/route-types/codegen.ts +6 -2
  161. package/src/build/route-types/per-module-writer.ts +94 -61
  162. package/src/cache/cache-runtime.ts +9 -0
  163. package/src/cache/cache-scope.ts +2 -1
  164. package/src/cache/cf/cf-cache-store.ts +2 -0
  165. package/src/cache/cf/cf-shell-frame.ts +3 -0
  166. package/src/cache/types.ts +8 -0
  167. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  168. package/src/client-urls/client-urls.ts +202 -153
  169. package/src/client-urls/revalidate-registry.ts +12 -3
  170. package/src/client-urls/server-projection.ts +137 -66
  171. package/src/client-urls/types.ts +30 -15
  172. package/src/client.rsc.tsx +11 -1
  173. package/src/client.tsx +9 -1
  174. package/src/decode-loader-results.ts +64 -31
  175. package/src/href-client.ts +3 -1
  176. package/src/index.rsc.ts +1 -0
  177. package/src/index.ts +12 -4
  178. package/src/loader-redirect.tsx +2 -3
  179. package/src/outlet-context.ts +7 -6
  180. package/src/prefetch-markers.ts +15 -5
  181. package/src/reverse.ts +3 -3
  182. package/src/route-content-wrapper.tsx +19 -17
  183. package/src/route-definition/dsl-helpers.ts +27 -7
  184. package/src/route-definition/helpers-types.ts +7 -2
  185. package/src/route-definition/resolve-handler-use.ts +23 -10
  186. package/src/route-definition/rewrite.ts +86 -19
  187. package/src/route-types.ts +1 -2
  188. package/src/router/find-match.ts +37 -1
  189. package/src/router/handler-context.ts +85 -40
  190. package/src/router/intercept-resolution.ts +161 -307
  191. package/src/router/lazy-includes.ts +43 -27
  192. package/src/router/loader-resolution.ts +21 -1
  193. package/src/router/manifest.ts +6 -8
  194. package/src/router/match-api.ts +11 -1
  195. package/src/router/match-middleware/background-revalidation.ts +2 -4
  196. package/src/router/match-middleware/cache-lookup.ts +16 -20
  197. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  198. package/src/router/match-result.ts +31 -2
  199. package/src/router/middleware.ts +6 -147
  200. package/src/router/navigation-snapshot.ts +38 -2
  201. package/src/router/prefetch-cache-ttl.ts +10 -18
  202. package/src/router/prerender-match.ts +2 -1
  203. package/src/router/revalidation.ts +24 -3
  204. package/src/router/router-context.ts +5 -18
  205. package/src/router/router-interfaces.ts +0 -7
  206. package/src/router/router-options.ts +4 -7
  207. package/src/router/segment-resolution/fresh.ts +6 -2
  208. package/src/router/segment-resolution/helpers.ts +7 -1
  209. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  210. package/src/router/segment-resolution/revalidation.ts +75 -35
  211. package/src/router/segment-resolution.ts +0 -1
  212. package/src/router/segment-wrappers.ts +8 -13
  213. package/src/router/substitute-pattern-params.ts +3 -2
  214. package/src/router/tracing.ts +2 -2
  215. package/src/router.ts +2 -9
  216. package/src/rsc/full-payload.ts +26 -4
  217. package/src/rsc/handler.ts +83 -41
  218. package/src/rsc/helpers.ts +13 -0
  219. package/src/rsc/manifest-init.ts +56 -3
  220. package/src/rsc/prefetch-render-error.ts +38 -0
  221. package/src/rsc/progressive-enhancement.ts +161 -211
  222. package/src/rsc/rewrite-passes.ts +56 -9
  223. package/src/rsc/rsc-rendering.ts +79 -23
  224. package/src/rsc/server-action.ts +25 -2
  225. package/src/rsc/shell-capture-constants.ts +22 -0
  226. package/src/rsc/shell-capture.ts +11 -0
  227. package/src/rsc/types.ts +5 -0
  228. package/src/segment-system.tsx +125 -119
  229. package/src/server/context.ts +96 -5
  230. package/src/server/handle-store.ts +3 -2
  231. package/src/server/request-context.ts +42 -3
  232. package/src/server/request-headers.ts +32 -0
  233. package/src/server/rewrite-trail.ts +55 -9
  234. package/src/ssr/ssr-root.tsx +6 -10
  235. package/src/testing/dispatch.ts +4 -5
  236. package/src/testing/e2e/index.ts +1 -0
  237. package/src/testing/flight.entry.ts +1 -0
  238. package/src/testing/index.ts +1 -0
  239. package/src/testing/internal/client-urls-projection.ts +31 -11
  240. package/src/testing/render-route.tsx +83 -40
  241. package/src/testing/run-loader.ts +26 -2
  242. package/src/testing/run-middleware.ts +10 -0
  243. package/src/testing/run-revalidate.ts +13 -6
  244. package/src/testing/serve-shell-request.ts +77 -4
  245. package/src/testing/shell-status.ts +40 -0
  246. package/src/testing/vitest-stubs/version.ts +1 -0
  247. package/src/testing/vitest.ts +74 -7
  248. package/src/thenable-status.ts +0 -11
  249. package/src/types/handler-context.ts +12 -1
  250. package/src/types/loader-types.ts +4 -3
  251. package/src/types/route-entry.ts +2 -1
  252. package/src/urls/include-helper.ts +38 -11
  253. package/src/urls/path-helper-types.ts +4 -2
  254. package/src/vite/discovery/client-urls-projection.ts +205 -16
  255. package/src/vite/discovery/discover-routers.ts +2 -2
  256. package/src/vite/discovery/prerender-collection.ts +2 -6
  257. package/src/vite/discovery/state.ts +26 -0
  258. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  259. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  260. package/src/vite/plugins/hoist/source.ts +8 -7
  261. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  262. package/src/vite/plugins/version-plugin.ts +7 -0
  263. package/src/vite/plugins/version.d.ts +7 -0
  264. package/src/vite/plugins/virtual-entries.ts +20 -0
  265. package/src/vite/rango.ts +4 -1
  266. package/src/vite/router-discovery.ts +39 -7
  267. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -47,6 +47,8 @@ export interface ShellFrameHead {
47
47
  ta?: number;
48
48
  /** ShellCacheEntry.initialTheme */
49
49
  i?: string;
50
+ /** ShellCacheEntry.pathname (a visitor-keyed shell's rendered location). */
51
+ lp?: string;
50
52
  /** ShellCacheEntry.docKey; navigation replay needs it after any round trip. */
51
53
  dk?: string;
52
54
  /** ShellCacheEntry.prunedRecords (diagnostic). */
@@ -79,6 +81,7 @@ function isShellFrameHead(value: unknown): value is ShellFrameHead {
79
81
  head.t.every((tag) => typeof tag === "string"))) &&
80
82
  (head.ta === undefined || isFiniteNumber(head.ta)) &&
81
83
  (head.i === undefined || typeof head.i === "string") &&
84
+ (head.lp === undefined || typeof head.lp === "string") &&
82
85
  (head.dk === undefined || typeof head.dk === "string") &&
83
86
  (head.pr === undefined || typeof head.pr === "string") &&
84
87
  (head.no === undefined || head.no === true) &&
@@ -468,6 +468,14 @@ export interface ShellCacheEntry {
468
468
  * ThemeProvider.
469
469
  */
470
470
  initialTheme?: string;
471
+ /**
472
+ * The payload pathname of a shell keyed by the visitor's URL
473
+ * (`rewrite(...).shell("visitor")`, visitorShellKey); absent when the
474
+ * capture rendered its key's own URL. The serve tail replays it as it does
475
+ * `initialTheme`, and the serve gate serves the entry only to a request
476
+ * that renders this location.
477
+ */
478
+ pathname?: string;
471
479
  /**
472
480
  * The CAPTURE DATA SNAPSHOT, in stored/serialized form: the doc segment
473
481
  * record every HIT replays the handler layer from (`docKey`) and the
@@ -271,6 +271,8 @@ interface VercelShellEnvelope {
271
271
  t?: string[];
272
272
  /** initialTheme the capture render was built with (resume theme fidelity). */
273
273
  i?: string;
274
+ /** ShellCacheEntry.pathname (a visitor-keyed shell's rendered location). */
275
+ lp?: string;
274
276
  /** Capture data snapshot (ShellCacheEntry.snapshot). */
275
277
  sn: ShellSnapshotRecord[];
276
278
  /**
@@ -1158,6 +1160,7 @@ export class VercelCacheStore<
1158
1160
  reactVersion: env.rv,
1159
1161
  buildVersion: env.bv,
1160
1162
  initialTheme: env.i,
1163
+ pathname: env.lp,
1161
1164
  snapshot: env.sn,
1162
1165
  docKey: env.dk,
1163
1166
  prunedRecords: env.pr,
@@ -1326,6 +1329,7 @@ export class VercelCacheStore<
1326
1329
  e: Math.min(expiresAt, now + retentionTtl * 1000),
1327
1330
  t: safeTags.length > 0 ? safeTags : undefined,
1328
1331
  i: entry.initialTheme,
1332
+ lp: entry.pathname,
1329
1333
  sn: entry.snapshot,
1330
1334
  dk: entry.docKey,
1331
1335
  pr: entry.prunedRecords,
@@ -1743,7 +1747,7 @@ export class VercelCacheStore<
1743
1747
 
1744
1748
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1745
1749
  if (!isRecord(raw)) return null;
1746
- const { p, po, rv, bv, c, s, e, t, i, sn, dk, pr, no } = raw;
1750
+ const { p, po, rv, bv, c, s, e, t, i, lp, sn, dk, pr, no } = raw;
1747
1751
  if (typeof rv !== "string" || typeof bv !== "string") return null;
1748
1752
  // Document half: required unless navigationOnly (`no`), which stores
1749
1753
  // neither field. Tolerate a legacy navigationOnly envelope that still
@@ -1766,6 +1770,7 @@ export class VercelCacheStore<
1766
1770
  e,
1767
1771
  t: Array.isArray(t) ? (t as string[]) : undefined,
1768
1772
  i: typeof i === "string" ? i : undefined,
1773
+ lp: typeof lp === "string" ? lp : undefined,
1769
1774
  // A non-array field reads as no records.
1770
1775
  sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : [],
1771
1776
  dk: typeof dk === "string" ? dk : undefined,
@@ -9,6 +9,7 @@ import type {
9
9
  ClientPathOptions,
10
10
  ClientTransitionConfig,
11
11
  ClientUrlBuilder,
12
+ ClientUrlComponent,
12
13
  ClientUrlHelpers,
13
14
  ClientUrlInterceptRecord,
14
15
  ClientUrlItem,
@@ -224,10 +225,60 @@ function rejectItems(
224
225
  }
225
226
  }
226
227
 
228
+ const STRUCTURAL_ITEMS = new Set<InternalItem["type"]>([
229
+ "path",
230
+ "layout",
231
+ "intercept",
232
+ ]);
233
+ const LEAF_ITEMS = new Set<InternalItem["type"]>(["loader", "loading"]);
234
+ const PATH_USE_ITEMS = new Set<InternalItem["type"]>([
235
+ "loader",
236
+ "loading",
237
+ "transition",
238
+ ]);
239
+
240
+ /**
241
+ * A mounted component's own items (`Dashboard.use = () => [...]`, the server
242
+ * handler.use pattern), validated for the mount site, followed by the site's
243
+ * `explicit` items, as mergeHandlerUse orders them on the server
244
+ * (route-definition/resolve-handler-use.ts): an explicit loading() or
245
+ * transition() wins, loaders accumulate.
246
+ */
247
+ function componentUse(
248
+ component: ClientUrlComponent,
249
+ allowed: ReadonlySet<InternalItem["type"]>,
250
+ explicit: InternalItem[],
251
+ ): InternalItem[] {
252
+ const use = component.use;
253
+ if (use === undefined) return explicit;
254
+ const label = `${component.name}.use`;
255
+ const items = runUse(use, label);
256
+ if (process.env.NODE_ENV !== "production") {
257
+ const structural = items.find((item) => STRUCTURAL_ITEMS.has(item.type));
258
+ if (structural) {
259
+ throw new Error(
260
+ `clientUrls() does not support ${structural.type}() inside ${label}: ` +
261
+ "routes come from the clientUrls() builder's own items, which " +
262
+ "route types and .gen.ts are generated from. Declare it there.",
263
+ );
264
+ }
265
+ }
266
+ rejectItems(items, allowed, label);
267
+ if (process.env.NODE_ENV !== "production") assertTransitionCount(items);
268
+ return [...items, ...explicit];
269
+ }
270
+
271
+ /** Dev-only: in production the last transition() wins (addPathRecord). */
272
+ function assertTransitionCount(items: readonly InternalItem[]): void {
273
+ if (items.filter((item) => item.type === "transition").length > 1) {
274
+ throw new Error("clientUrls() path() received more than one transition()");
275
+ }
276
+ }
277
+
227
278
  function assertNamedComponent(
228
279
  component: unknown,
229
280
  helper: "path" | "layout" | "intercept",
230
- ): asserts component is ComponentType {
281
+ ): asserts component is ClientUrlComponent {
231
282
  // Build-time handler wrappers get a TARGETED rejection ahead of the generic
232
283
  // shape check: their handler code cannot live in a "use client" bundle and
233
284
  // their render runs at build, so accepting one could only fail later and
@@ -276,10 +327,139 @@ function assertLoaderDefinition(
276
327
  }
277
328
  }
278
329
 
330
+ // Stateless item constructors: the builder callback hands these out, and
331
+ // `@rangojs/router/client` exports them for a component's `.use`.
332
+
333
+ export const loader: ClientUrlHelpers["loader"] = (
334
+ definition,
335
+ optionsOrUse,
336
+ maybeUse,
337
+ ) => {
338
+ assertLoaderDefinition(definition);
339
+ // Same options-or-use disambiguation as the server DSL loader(): the
340
+ // 2-arg loader(Def, use) form is untouched.
341
+ if (typeof optionsOrUse === "function" && maybeUse !== undefined) {
342
+ throw new Error(
343
+ "clientUrls() loader() received two use() callbacks. Pass loader(Def, options, use) or loader(Def, use).",
344
+ );
345
+ }
346
+ const options = typeof optionsOrUse === "function" ? undefined : optionsOrUse;
347
+ const use = typeof optionsOrUse === "function" ? optionsOrUse : maybeUse;
348
+ if ((options as { stream?: unknown } | undefined)?.stream !== undefined) {
349
+ throw new Error(
350
+ "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.",
351
+ );
352
+ }
353
+ // Inline, not errors.ts assertBooleanOption: this module is in the
354
+ // client router chunk, where the helper costs 20 B gzip.
355
+ if (options?.ssr !== undefined && typeof options.ssr !== "boolean") {
356
+ throw new Error(
357
+ `clientUrls() loader() ssr must be a boolean (got ${JSON.stringify(options.ssr)}). Omit it (or pass true) to stream on every render.`,
358
+ );
359
+ }
360
+ if (
361
+ process.env.NODE_ENV !== "production" &&
362
+ options?.prefetch !== undefined &&
363
+ typeof options.prefetch !== "boolean"
364
+ ) {
365
+ throw new Error(
366
+ `clientUrls() loader() prefetch must be a boolean (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
367
+ );
368
+ }
369
+ const items = use ? runUse(use, "loader use") : [];
370
+ rejectItems(items, new Set(["revalidate"]), "loader");
371
+ return toPublicItem({
372
+ [ITEM_KIND]: true,
373
+ type: "loader",
374
+ definition,
375
+ revalidate: items
376
+ .filter(
377
+ (item): item is Extract<InternalItem, { type: "revalidate" }> =>
378
+ item.type === "revalidate",
379
+ )
380
+ .map((item) => item.fn),
381
+ ...(options?.ssr === false ? { ssr: false as const } : {}),
382
+ ...(options?.prefetch === false ? { prefetch: false as const } : {}),
383
+ });
384
+ };
385
+
386
+ export const revalidate: ClientUrlHelpers["revalidate"] = (fn) => {
387
+ // Dev-only: client-urls-revalidate-hoist.ts rejects the rest at build.
388
+ if (
389
+ process.env.NODE_ENV !== "production" &&
390
+ typeof fn !== "function" &&
391
+ typeof fn !== "boolean" &&
392
+ !isHoistedRevalidateRef(fn)
393
+ ) {
394
+ throw new Error(
395
+ "clientUrls() revalidate() expects a predicate function or a boolean",
396
+ );
397
+ }
398
+ return toPublicItem({
399
+ [ITEM_KIND]: true,
400
+ type: "revalidate",
401
+ fn,
402
+ });
403
+ };
404
+
405
+ export const loading: ClientUrlHelpers["loading"] = (component) =>
406
+ toPublicItem({ [ITEM_KIND]: true, type: "loading", component });
407
+
408
+ export const transition: ClientUrlHelpers["transition"] = (config) => {
409
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
410
+ throw new Error("clientUrls() transition() expects a configuration object");
411
+ }
412
+ for (const key of Reflect.ownKeys(config)) {
413
+ if (!TRANSITION_CONFIG_KEYS.has(key)) {
414
+ throw new Error(
415
+ `clientUrls() transition() option ${JSON.stringify(String(key))} is not supported`,
416
+ );
417
+ }
418
+ }
419
+ if (config.name !== undefined && typeof config.name !== "string") {
420
+ throw new Error("clientUrls() transition() name must be a string");
421
+ }
422
+ if (config.when !== undefined && typeof config.when !== "function") {
423
+ throw new TypeError(
424
+ process.env.NODE_ENV !== "production"
425
+ ? `clientUrls() transition() when must be a function (it runs in the browser), got ${config.when === null ? "null" : typeof config.when}`
426
+ : "clientUrls() transition() when must be a function",
427
+ );
428
+ }
429
+ if (
430
+ config.viewTransition !== undefined &&
431
+ config.viewTransition !== "auto" &&
432
+ config.viewTransition !== false
433
+ ) {
434
+ throw new Error(
435
+ 'clientUrls() transition() viewTransition must be "auto" or false',
436
+ );
437
+ }
438
+ for (const key of ["enter", "exit", "update", "share", "default"] as const) {
439
+ const value = config[key];
440
+ if (value === undefined || typeof value === "string") continue;
441
+ const isStringMap =
442
+ typeof value === "object" &&
443
+ value !== null &&
444
+ !Array.isArray(value) &&
445
+ Object.values(value).every((entry) => typeof entry === "string");
446
+ if (!isStringMap) {
447
+ throw new Error(
448
+ `clientUrls() transition() ${key} must be a string or a string map of transition types`,
449
+ );
450
+ }
451
+ }
452
+ return toPublicItem({
453
+ [ITEM_KIND]: true,
454
+ type: "transition",
455
+ config: { ...config },
456
+ });
457
+ };
458
+
279
459
  function createHelpers(): ClientUrlHelpers {
280
460
  const path = ((
281
461
  pattern: string,
282
- component: ComponentType,
462
+ component: ClientUrlComponent,
283
463
  optionsOrUse?: ClientPathOptions | ClientUrlUse,
284
464
  maybeUse?: ClientUrlUse,
285
465
  ): ClientUrlItem => {
@@ -334,24 +514,20 @@ function createHelpers(): ClientUrlHelpers {
334
514
  }
335
515
 
336
516
  const items = use ? runUse(use, "path use") : [];
337
- rejectItems(items, new Set(["loader", "loading", "transition"]), "path");
338
- if (items.filter((item) => item.type === "transition").length > 1) {
339
- throw new Error(
340
- "clientUrls() path() received more than one transition()",
341
- );
342
- }
517
+ rejectItems(items, PATH_USE_ITEMS, "path");
518
+ if (process.env.NODE_ENV !== "production") assertTransitionCount(items);
343
519
  return toPublicItem({
344
520
  [ITEM_KIND]: true,
345
521
  type: "path",
346
522
  pattern,
347
523
  component,
348
524
  options,
349
- use: items,
525
+ use: componentUse(component, PATH_USE_ITEMS, items),
350
526
  });
351
527
  }) as ClientUrlHelpers["path"];
352
528
 
353
529
  const layout = ((
354
- component: ComponentType,
530
+ component: ClientUrlComponent,
355
531
  children: ClientUrlUse,
356
532
  ): ClientUrlItem => {
357
533
  assertNamedComponent(component, "layout");
@@ -361,147 +537,16 @@ function createHelpers(): ClientUrlHelpers {
361
537
  new Set(["path", "layout", "loader", "loading", "intercept"]),
362
538
  "layout",
363
539
  );
540
+ // Merged here, before flattenLayout's applyConfig creates the layout's
541
+ // loader records once for every route below it (layoutLoaders identity).
364
542
  return toPublicItem({
365
543
  [ITEM_KIND]: true,
366
544
  type: "layout",
367
545
  component,
368
- children: items,
546
+ children: componentUse(component, LEAF_ITEMS, items),
369
547
  });
370
548
  }) as ClientUrlHelpers["layout"];
371
549
 
372
- const loader: ClientUrlHelpers["loader"] = (
373
- definition,
374
- optionsOrUse,
375
- maybeUse,
376
- ) => {
377
- assertLoaderDefinition(definition);
378
- // Same options-or-use disambiguation as the server DSL loader(): the
379
- // 2-arg loader(Def, use) form is untouched.
380
- if (typeof optionsOrUse === "function" && maybeUse !== undefined) {
381
- throw new Error(
382
- "clientUrls() loader() received two use() callbacks. Pass loader(Def, options, use) or loader(Def, use).",
383
- );
384
- }
385
- const options =
386
- typeof optionsOrUse === "function" ? undefined : optionsOrUse;
387
- const use = typeof optionsOrUse === "function" ? optionsOrUse : maybeUse;
388
- if ((options as { stream?: unknown } | undefined)?.stream !== undefined) {
389
- throw new Error(
390
- "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.",
391
- );
392
- }
393
- // Inline, not errors.ts assertBooleanOption: this module is in the
394
- // client router chunk, where the helper costs 20 B gzip.
395
- if (options?.ssr !== undefined && typeof options.ssr !== "boolean") {
396
- throw new Error(
397
- `clientUrls() loader() ssr must be a boolean (got ${JSON.stringify(options.ssr)}). Omit it (or pass true) to stream on every render.`,
398
- );
399
- }
400
- if (
401
- process.env.NODE_ENV !== "production" &&
402
- options?.prefetch !== undefined &&
403
- typeof options.prefetch !== "boolean"
404
- ) {
405
- throw new Error(
406
- `clientUrls() loader() prefetch must be a boolean (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
407
- );
408
- }
409
- const items = use ? runUse(use, "loader use") : [];
410
- rejectItems(items, new Set(["revalidate"]), "loader");
411
- return toPublicItem({
412
- [ITEM_KIND]: true,
413
- type: "loader",
414
- definition,
415
- revalidate: items
416
- .filter(
417
- (item): item is Extract<InternalItem, { type: "revalidate" }> =>
418
- item.type === "revalidate",
419
- )
420
- .map((item) => item.fn),
421
- ...(options?.ssr === false ? { ssr: false as const } : {}),
422
- ...(options?.prefetch === false ? { prefetch: false as const } : {}),
423
- });
424
- };
425
-
426
- const revalidate: ClientUrlHelpers["revalidate"] = (fn) => {
427
- // The build leaves a marker in place of the predicate it moved to the
428
- // server (vite/plugins/client-urls-revalidate-hoist.ts).
429
- if (typeof fn !== "function" && !isHoistedRevalidateRef(fn)) {
430
- throw new Error("clientUrls() revalidate() expects a predicate function");
431
- }
432
- return toPublicItem({
433
- [ITEM_KIND]: true,
434
- type: "revalidate",
435
- fn,
436
- });
437
- };
438
-
439
- const loading: ClientUrlHelpers["loading"] = (component) =>
440
- toPublicItem({ [ITEM_KIND]: true, type: "loading", component });
441
-
442
- const transition: ClientUrlHelpers["transition"] = (config) => {
443
- if (
444
- typeof config !== "object" ||
445
- config === null ||
446
- Array.isArray(config)
447
- ) {
448
- throw new Error(
449
- "clientUrls() transition() expects a configuration object",
450
- );
451
- }
452
- for (const key of Reflect.ownKeys(config)) {
453
- if (!TRANSITION_CONFIG_KEYS.has(key)) {
454
- throw new Error(
455
- `clientUrls() transition() option ${JSON.stringify(String(key))} is not supported`,
456
- );
457
- }
458
- }
459
- if (config.name !== undefined && typeof config.name !== "string") {
460
- throw new Error("clientUrls() transition() name must be a string");
461
- }
462
- if (config.when !== undefined && typeof config.when !== "function") {
463
- throw new TypeError(
464
- process.env.NODE_ENV !== "production"
465
- ? `clientUrls() transition() when must be a function (it runs in the browser), got ${config.when === null ? "null" : typeof config.when}`
466
- : "clientUrls() transition() when must be a function",
467
- );
468
- }
469
- if (
470
- config.viewTransition !== undefined &&
471
- config.viewTransition !== "auto" &&
472
- config.viewTransition !== false
473
- ) {
474
- throw new Error(
475
- 'clientUrls() transition() viewTransition must be "auto" or false',
476
- );
477
- }
478
- for (const key of [
479
- "enter",
480
- "exit",
481
- "update",
482
- "share",
483
- "default",
484
- ] as const) {
485
- const value = config[key];
486
- if (value === undefined || typeof value === "string") continue;
487
- const isStringMap =
488
- typeof value === "object" &&
489
- value !== null &&
490
- !Array.isArray(value) &&
491
- Object.values(value).every((entry) => typeof entry === "string");
492
- if (!isStringMap) {
493
- throw new Error(
494
- `clientUrls() transition() ${key} must be a string or a string map of transition types`,
495
- );
496
- }
497
- }
498
- return toPublicItem({
499
- [ITEM_KIND]: true,
500
- type: "transition",
501
- config: { ...config },
502
- });
503
- };
504
-
505
550
  const intercept: ClientUrlHelpers["intercept"] = (
506
551
  slotName,
507
552
  targetName,
@@ -523,8 +568,9 @@ function createHelpers(): ClientUrlHelpers {
523
568
  );
524
569
  }
525
570
  assertNamedComponent(component, "intercept");
526
- const items = use ? runUse(use, "intercept use") : [];
527
- rejectItems(items, new Set(["loader", "loading"]), "intercept");
571
+ const own = use ? runUse(use, "intercept use") : [];
572
+ rejectItems(own, LEAF_ITEMS, "intercept");
573
+ const items = componentUse(component, LEAF_ITEMS, own);
528
574
  for (const item of items) {
529
575
  // Intercept loaders run on soft navigations only — a document-render
530
576
  // await can never apply, so accepting the flag would be silently inert.
@@ -608,11 +654,12 @@ function addPathRecord(
608
654
  const routeContext = applyConfig(context, item.use);
609
655
  const options = item.options ? Object.freeze({ ...item.options }) : undefined;
610
656
  // Route-scoped only: transition() is rejected outside path() use callbacks,
611
- // so there is no layout-level inheritance to fold in.
612
- const transitionItem = item.use.find(
613
- (useItem): useItem is Extract<InternalItem, { type: "transition" }> =>
614
- useItem.type === "transition",
615
- );
657
+ // so there is no layout-level inheritance to fold in. The last one wins:
658
+ // the explicit use callback's over the component's `.use`.
659
+ let transitionItem: Extract<InternalItem, { type: "transition" }> | undefined;
660
+ for (const useItem of item.use) {
661
+ if (useItem.type === "transition") transitionItem = useItem;
662
+ }
616
663
  routes.push(
617
664
  Object.freeze({
618
665
  id: `client-route-${routes.length}`,
@@ -703,12 +750,13 @@ function collectIntercepts(
703
750
  let loading: ReactNode | undefined;
704
751
  for (const useItem of item.use) {
705
752
  if (useItem.type === "loader") {
706
- // `prefetch` is not carried: an intercept prefetch defers nothing,
707
- // so the flag is accepted and ignored, as on a urls() intercept().
708
753
  loaders.push(
709
754
  Object.freeze({
710
755
  loader: useItem.definition,
711
756
  revalidate: Object.freeze([...useItem.revalidate]),
757
+ ...(useItem.prefetch === false
758
+ ? { prefetch: false as const }
759
+ : {}),
712
760
  }),
713
761
  );
714
762
  } else if (useItem.type === "loading") {
@@ -824,6 +872,7 @@ export function buildClientUrlsTrie(
824
872
  export type {
825
873
  ClientTransitionConfig,
826
874
  ClientUrlBuilder,
875
+ ClientUrlComponent,
827
876
  ClientUrlHelpers,
828
877
  ClientUrlInterceptRecord,
829
878
  ClientUrlItem,
@@ -1,3 +1,4 @@
1
+ import { toShouldRevalidateFn } from "../router/revalidation.js";
1
2
  import type { ShouldRevalidateFn } from "../types.js";
2
3
 
3
4
  /**
@@ -6,7 +7,9 @@ import type { ShouldRevalidateFn } from "../types.js";
6
7
  * group's module loads in the RSC realm
7
8
  * (vite/plugins/client-urls-revalidate-hoist.ts); materialized loader stubs
8
9
  * 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
+ * syntax, so a re-registration under one key is the same predicate. A
11
+ * hoisted boolean (`revalidate(FLAG)`) is stored as its constant predicate;
12
+ * any other value is left unregistered for materialization to name.
10
13
  *
11
14
  * Server-only build output, not router state: no router hook reads it
12
15
  * (AGENTS.md Hard rule 8).
@@ -14,9 +17,15 @@ import type { ShouldRevalidateFn } from "../types.js";
14
17
  const revalidators = new Map<string, ShouldRevalidateFn<any, any>>();
15
18
 
16
19
  export function registerClientUrlRevalidators(
17
- fns: Readonly<Record<string, ShouldRevalidateFn<any, any>>>,
20
+ fns: Readonly<Record<string, ShouldRevalidateFn<any, any> | boolean>>,
18
21
  ): void {
19
- for (const [key, fn] of Object.entries(fns)) revalidators.set(key, fn);
22
+ for (const [key, fn] of Object.entries(fns)) {
23
+ if (typeof fn === "function" || typeof fn === "boolean") {
24
+ revalidators.set(key, toShouldRevalidateFn(fn));
25
+ } else {
26
+ revalidators.delete(key);
27
+ }
28
+ }
20
29
  }
21
30
 
22
31
  export function getClientUrlRevalidator(