@rangojs/router 0.24.3 → 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 (274) 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-root.d.ts +4 -1
  23. package/dist/types/client-urls/client-urls.d.ts +28 -2
  24. package/dist/types/client-urls/navigation.d.ts +6 -8
  25. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  26. package/dist/types/client-urls/server-projection.d.ts +20 -5
  27. package/dist/types/client-urls/types.d.ts +35 -17
  28. package/dist/types/client.d.ts +2 -2
  29. package/dist/types/client.rsc.d.ts +2 -2
  30. package/dist/types/decode-loader-results.d.ts +28 -7
  31. package/dist/types/href-client.d.ts +3 -1
  32. package/dist/types/index.rsc.d.ts +1 -1
  33. package/dist/types/loader-redirect.d.ts +2 -3
  34. package/dist/types/outlet-context.d.ts +7 -6
  35. package/dist/types/prefetch-markers.d.ts +14 -5
  36. package/dist/types/route-content-wrapper.d.ts +17 -14
  37. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  38. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  39. package/dist/types/route-definition/rewrite.d.ts +48 -5
  40. package/dist/types/route-types.d.ts +2 -2
  41. package/dist/types/router/handler-context.d.ts +14 -2
  42. package/dist/types/router/intercept-resolution.d.ts +19 -46
  43. package/dist/types/router/lazy-includes.d.ts +7 -0
  44. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  45. package/dist/types/router/middleware.d.ts +0 -16
  46. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  47. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  48. package/dist/types/router/revalidation.d.ts +8 -1
  49. package/dist/types/router/router-context.d.ts +4 -16
  50. package/dist/types/router/router-interfaces.d.ts +0 -6
  51. package/dist/types/router/router-options.d.ts +4 -7
  52. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  53. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  54. package/dist/types/router/segment-resolution.d.ts +1 -1
  55. package/dist/types/router/segment-wrappers.d.ts +3 -5
  56. package/dist/types/router/substitute-pattern-params.d.ts +19 -2
  57. package/dist/types/router/tracing.d.ts +2 -2
  58. package/dist/types/rsc/full-payload.d.ts +14 -2
  59. package/dist/types/rsc/helpers.d.ts +8 -0
  60. package/dist/types/rsc/manifest-init.d.ts +20 -0
  61. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  62. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  63. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  64. package/dist/types/rsc/server-action.d.ts +6 -0
  65. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  66. package/dist/types/rsc/shell-capture.d.ts +7 -0
  67. package/dist/types/rsc/types.d.ts +5 -0
  68. package/dist/types/segment-system.d.ts +15 -0
  69. package/dist/types/server/context.d.ts +64 -4
  70. package/dist/types/server/request-context.d.ts +41 -4
  71. package/dist/types/server/request-headers.d.ts +12 -0
  72. package/dist/types/server/rewrite-trail.d.ts +8 -1
  73. package/dist/types/testing/e2e/index.d.ts +1 -1
  74. package/dist/types/testing/flight.entry.d.ts +1 -1
  75. package/dist/types/testing/index.d.ts +1 -1
  76. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  77. package/dist/types/testing/render-route.d.ts +21 -11
  78. package/dist/types/testing/run-loader.d.ts +7 -0
  79. package/dist/types/testing/run-middleware.d.ts +7 -0
  80. package/dist/types/testing/run-revalidate.d.ts +5 -4
  81. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  82. package/dist/types/testing/shell-status.d.ts +17 -0
  83. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  84. package/dist/types/testing/vitest.d.ts +10 -1
  85. package/dist/types/thenable-status.d.ts +0 -6
  86. package/dist/types/types/handler-context.d.ts +12 -1
  87. package/dist/types/types/loader-types.d.ts +4 -3
  88. package/dist/types/types/route-entry.d.ts +2 -1
  89. package/dist/types/urls/path-helper-types.d.ts +4 -2
  90. package/dist/types/urls/path-helper.d.ts +5 -0
  91. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  92. package/dist/types/vite/discovery/state.d.ts +20 -0
  93. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  94. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  95. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  96. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  97. package/dist/vite/index.js +6816 -6547
  98. package/package.json +1 -1
  99. package/skills/app-structure/SKILL.md +255 -0
  100. package/skills/app-structure/content-site.md +333 -0
  101. package/skills/app-structure/e-commerce.md +435 -0
  102. package/skills/app-structure/large-app.md +885 -0
  103. package/skills/app-structure/multi-app.md +280 -0
  104. package/skills/breadcrumbs/SKILL.md +6 -1
  105. package/skills/caching/SKILL.md +3 -1
  106. package/skills/catalog.json +12 -0
  107. package/skills/client-urls/SKILL.md +195 -32
  108. package/skills/composability/SKILL.md +10 -8
  109. package/skills/handler-use/SKILL.md +48 -9
  110. package/skills/hooks/handle-and-actions.md +104 -0
  111. package/skills/hooks/navigation.md +24 -10
  112. package/skills/hooks/urls.md +1 -1
  113. package/skills/i18n/SKILL.md +2 -1
  114. package/skills/intercept/SKILL.md +60 -29
  115. package/skills/layout/SKILL.md +6 -3
  116. package/skills/links/SKILL.md +151 -17
  117. package/skills/loader/SKILL.md +32 -10
  118. package/skills/middleware/SKILL.md +111 -15
  119. package/skills/observability/SKILL.md +11 -0
  120. package/skills/parallel/SKILL.md +5 -5
  121. package/skills/ppr/SKILL.md +43 -8
  122. package/skills/prerender/SKILL.md +1 -2
  123. package/skills/rango/SKILL.md +3 -0
  124. package/skills/response-routes/SKILL.md +4 -1
  125. package/skills/scripts/SKILL.md +12 -1
  126. package/skills/server-actions/SKILL.md +60 -20
  127. package/skills/testing/SKILL.md +1 -0
  128. package/skills/testing/client-components.md +13 -10
  129. package/skills/testing/loader.md +25 -24
  130. package/skills/testing/middleware.md +11 -11
  131. package/skills/testing/response-routes.md +4 -1
  132. package/skills/testing/serve-shell-request.md +85 -21
  133. package/skills/ux-architecture/SKILL.md +980 -0
  134. package/skills/view-transitions/SKILL.md +43 -10
  135. package/src/__augment-tests__/augmented.check.ts +28 -0
  136. package/src/browser/event-controller.ts +44 -18
  137. package/src/browser/intercept-utils.ts +3 -2
  138. package/src/browser/merge-segment-loaders.ts +0 -85
  139. package/src/browser/navigation-bridge.ts +119 -98
  140. package/src/browser/navigation-client.ts +20 -15
  141. package/src/browser/navigation-store.ts +4 -2
  142. package/src/browser/navigation-transaction.ts +9 -8
  143. package/src/browser/partial-update.ts +90 -32
  144. package/src/browser/prefetch/cache.ts +8 -0
  145. package/src/browser/prefetch/fetch.ts +13 -3
  146. package/src/browser/rango-marker.ts +13 -0
  147. package/src/browser/react/NavigationProvider.tsx +25 -7
  148. package/src/browser/react/filter-segment-order.ts +13 -0
  149. package/src/browser/react/use-action.ts +9 -15
  150. package/src/browser/react/use-handle.ts +63 -71
  151. package/src/browser/react/use-href.tsx +24 -5
  152. package/src/browser/react/use-link-status.ts +1 -1
  153. package/src/browser/react/use-navigation.ts +30 -40
  154. package/src/browser/react/use-reverse.ts +4 -17
  155. package/src/browser/react/use-router.ts +37 -6
  156. package/src/browser/react/use-segments.ts +8 -12
  157. package/src/browser/response-adapter.ts +11 -5
  158. package/src/browser/rsc-router.tsx +3 -1
  159. package/src/browser/scroll-restoration.ts +34 -20
  160. package/src/browser/segment-reconciler.ts +11 -29
  161. package/src/browser/server-action-bridge.ts +52 -16
  162. package/src/browser/types.ts +40 -8
  163. package/src/build/generate-manifest.ts +12 -1
  164. package/src/build/route-types/codegen.ts +6 -2
  165. package/src/build/route-types/per-module-writer.ts +94 -61
  166. package/src/cache/cache-runtime.ts +9 -0
  167. package/src/cache/cache-scope.ts +2 -1
  168. package/src/cache/cf/cf-cache-store.ts +2 -0
  169. package/src/cache/cf/cf-shell-frame.ts +3 -0
  170. package/src/cache/types.ts +8 -0
  171. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  172. package/src/client-urls/client-root.tsx +10 -5
  173. package/src/client-urls/client-urls.ts +246 -166
  174. package/src/client-urls/navigation.ts +45 -38
  175. package/src/client-urls/revalidate-registry.ts +12 -3
  176. package/src/client-urls/server-projection.ts +286 -75
  177. package/src/client-urls/types.ts +40 -16
  178. package/src/client.rsc.tsx +11 -1
  179. package/src/client.tsx +9 -1
  180. package/src/decode-loader-results.ts +64 -31
  181. package/src/href-client.ts +3 -1
  182. package/src/index.rsc.ts +1 -0
  183. package/src/index.ts +12 -4
  184. package/src/loader-redirect.tsx +2 -3
  185. package/src/outlet-context.ts +7 -6
  186. package/src/prefetch-markers.ts +15 -5
  187. package/src/reverse.ts +3 -3
  188. package/src/route-content-wrapper.tsx +19 -17
  189. package/src/route-definition/dsl-helpers.ts +27 -7
  190. package/src/route-definition/helpers-types.ts +7 -2
  191. package/src/route-definition/resolve-handler-use.ts +23 -10
  192. package/src/route-definition/rewrite.ts +86 -19
  193. package/src/route-types.ts +1 -2
  194. package/src/router/find-match.ts +37 -1
  195. package/src/router/handler-context.ts +85 -40
  196. package/src/router/intercept-resolution.ts +161 -307
  197. package/src/router/lazy-includes.ts +43 -27
  198. package/src/router/loader-resolution.ts +21 -1
  199. package/src/router/manifest.ts +6 -8
  200. package/src/router/match-api.ts +11 -1
  201. package/src/router/match-middleware/background-revalidation.ts +2 -4
  202. package/src/router/match-middleware/cache-lookup.ts +16 -20
  203. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  204. package/src/router/match-result.ts +31 -2
  205. package/src/router/middleware.ts +6 -147
  206. package/src/router/navigation-snapshot.ts +38 -2
  207. package/src/router/prefetch-cache-ttl.ts +10 -18
  208. package/src/router/prerender-match.ts +2 -1
  209. package/src/router/revalidation.ts +24 -3
  210. package/src/router/router-context.ts +5 -18
  211. package/src/router/router-interfaces.ts +0 -7
  212. package/src/router/router-options.ts +4 -7
  213. package/src/router/segment-resolution/fresh.ts +6 -2
  214. package/src/router/segment-resolution/helpers.ts +7 -1
  215. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  216. package/src/router/segment-resolution/revalidation.ts +75 -35
  217. package/src/router/segment-resolution.ts +0 -1
  218. package/src/router/segment-wrappers.ts +8 -13
  219. package/src/router/substitute-pattern-params.ts +37 -2
  220. package/src/router/tracing.ts +2 -2
  221. package/src/router.ts +2 -9
  222. package/src/rsc/full-payload.ts +26 -4
  223. package/src/rsc/handler.ts +83 -41
  224. package/src/rsc/helpers.ts +13 -0
  225. package/src/rsc/manifest-init.ts +56 -3
  226. package/src/rsc/prefetch-render-error.ts +38 -0
  227. package/src/rsc/progressive-enhancement.ts +161 -211
  228. package/src/rsc/rewrite-passes.ts +56 -9
  229. package/src/rsc/rsc-rendering.ts +79 -23
  230. package/src/rsc/server-action.ts +25 -2
  231. package/src/rsc/shell-capture-constants.ts +22 -0
  232. package/src/rsc/shell-capture.ts +11 -0
  233. package/src/rsc/types.ts +5 -0
  234. package/src/segment-system.tsx +125 -119
  235. package/src/server/context.ts +96 -5
  236. package/src/server/handle-store.ts +3 -2
  237. package/src/server/request-context.ts +42 -3
  238. package/src/server/request-headers.ts +32 -0
  239. package/src/server/rewrite-trail.ts +55 -9
  240. package/src/ssr/ssr-root.tsx +6 -10
  241. package/src/testing/dispatch.ts +4 -5
  242. package/src/testing/e2e/index.ts +1 -0
  243. package/src/testing/flight.entry.ts +1 -0
  244. package/src/testing/index.ts +1 -0
  245. package/src/testing/internal/client-urls-projection.ts +31 -11
  246. package/src/testing/render-route.tsx +83 -40
  247. package/src/testing/run-loader.ts +26 -2
  248. package/src/testing/run-middleware.ts +10 -0
  249. package/src/testing/run-revalidate.ts +13 -6
  250. package/src/testing/serve-shell-request.ts +77 -4
  251. package/src/testing/shell-status.ts +40 -0
  252. package/src/testing/vitest-stubs/version.ts +1 -0
  253. package/src/testing/vitest.ts +74 -7
  254. package/src/thenable-status.ts +0 -11
  255. package/src/types/handler-context.ts +12 -1
  256. package/src/types/loader-types.ts +4 -3
  257. package/src/types/route-entry.ts +2 -1
  258. package/src/urls/include-helper.ts +38 -11
  259. package/src/urls/path-helper-types.ts +4 -2
  260. package/src/urls/path-helper.ts +16 -3
  261. package/src/vite/discovery/client-urls-projection.ts +205 -16
  262. package/src/vite/discovery/discover-routers.ts +2 -2
  263. package/src/vite/discovery/prerender-collection.ts +2 -6
  264. package/src/vite/discovery/state.ts +26 -0
  265. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  266. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  267. package/src/vite/plugins/hoist/source.ts +8 -7
  268. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  269. package/src/vite/plugins/version-plugin.ts +7 -0
  270. package/src/vite/plugins/version.d.ts +7 -0
  271. package/src/vite/plugins/virtual-entries.ts +20 -0
  272. package/src/vite/rango.ts +4 -1
  273. package/src/vite/router-discovery.ts +39 -7
  274. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -1,6 +1,7 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import { validateUserRouteName } from "../route-name.js";
3
- import { buildRouteTrie } from "../router/route-trie-builder.js";
3
+ import { buildRouteTrie, type TrieNode } from "../router/route-trie-builder.js";
4
+ import { joinMount } from "../router/substitute-pattern-params.js";
4
5
  import { tryTrieMatch } from "../router/trie-matching.js";
5
6
  import type { LoaderDefinition } from "../types.js";
6
7
  import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
@@ -8,6 +9,7 @@ import type {
8
9
  ClientPathOptions,
9
10
  ClientTransitionConfig,
10
11
  ClientUrlBuilder,
12
+ ClientUrlComponent,
11
13
  ClientUrlHelpers,
12
14
  ClientUrlInterceptRecord,
13
15
  ClientUrlItem,
@@ -223,10 +225,60 @@ function rejectItems(
223
225
  }
224
226
  }
225
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
+
226
278
  function assertNamedComponent(
227
279
  component: unknown,
228
280
  helper: "path" | "layout" | "intercept",
229
- ): asserts component is ComponentType {
281
+ ): asserts component is ClientUrlComponent {
230
282
  // Build-time handler wrappers get a TARGETED rejection ahead of the generic
231
283
  // shape check: their handler code cannot live in a "use client" bundle and
232
284
  // their render runs at build, so accepting one could only fail later and
@@ -275,10 +327,139 @@ function assertLoaderDefinition(
275
327
  }
276
328
  }
277
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
+
278
459
  function createHelpers(): ClientUrlHelpers {
279
460
  const path = ((
280
461
  pattern: string,
281
- component: ComponentType,
462
+ component: ClientUrlComponent,
282
463
  optionsOrUse?: ClientPathOptions | ClientUrlUse,
283
464
  maybeUse?: ClientUrlUse,
284
465
  ): ClientUrlItem => {
@@ -333,24 +514,20 @@ function createHelpers(): ClientUrlHelpers {
333
514
  }
334
515
 
335
516
  const items = use ? runUse(use, "path use") : [];
336
- rejectItems(items, new Set(["loader", "loading", "transition"]), "path");
337
- if (items.filter((item) => item.type === "transition").length > 1) {
338
- throw new Error(
339
- "clientUrls() path() received more than one transition()",
340
- );
341
- }
517
+ rejectItems(items, PATH_USE_ITEMS, "path");
518
+ if (process.env.NODE_ENV !== "production") assertTransitionCount(items);
342
519
  return toPublicItem({
343
520
  [ITEM_KIND]: true,
344
521
  type: "path",
345
522
  pattern,
346
523
  component,
347
524
  options,
348
- use: items,
525
+ use: componentUse(component, PATH_USE_ITEMS, items),
349
526
  });
350
527
  }) as ClientUrlHelpers["path"];
351
528
 
352
529
  const layout = ((
353
- component: ComponentType,
530
+ component: ClientUrlComponent,
354
531
  children: ClientUrlUse,
355
532
  ): ClientUrlItem => {
356
533
  assertNamedComponent(component, "layout");
@@ -360,147 +537,16 @@ function createHelpers(): ClientUrlHelpers {
360
537
  new Set(["path", "layout", "loader", "loading", "intercept"]),
361
538
  "layout",
362
539
  );
540
+ // Merged here, before flattenLayout's applyConfig creates the layout's
541
+ // loader records once for every route below it (layoutLoaders identity).
363
542
  return toPublicItem({
364
543
  [ITEM_KIND]: true,
365
544
  type: "layout",
366
545
  component,
367
- children: items,
546
+ children: componentUse(component, LEAF_ITEMS, items),
368
547
  });
369
548
  }) as ClientUrlHelpers["layout"];
370
549
 
371
- const loader: ClientUrlHelpers["loader"] = (
372
- definition,
373
- optionsOrUse,
374
- maybeUse,
375
- ) => {
376
- assertLoaderDefinition(definition);
377
- // Same options-or-use disambiguation as the server DSL loader(): the
378
- // 2-arg loader(Def, use) form is untouched.
379
- if (typeof optionsOrUse === "function" && maybeUse !== undefined) {
380
- throw new Error(
381
- "clientUrls() loader() received two use() callbacks. Pass loader(Def, options, use) or loader(Def, use).",
382
- );
383
- }
384
- const options =
385
- typeof optionsOrUse === "function" ? undefined : optionsOrUse;
386
- const use = typeof optionsOrUse === "function" ? optionsOrUse : maybeUse;
387
- if ((options as { stream?: unknown } | undefined)?.stream !== undefined) {
388
- throw new Error(
389
- "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.",
390
- );
391
- }
392
- // Inline, not errors.ts assertBooleanOption: this module is in the
393
- // client router chunk, where the helper costs 20 B gzip.
394
- if (options?.ssr !== undefined && typeof options.ssr !== "boolean") {
395
- throw new Error(
396
- `clientUrls() loader() ssr must be a boolean (got ${JSON.stringify(options.ssr)}). Omit it (or pass true) to stream on every render.`,
397
- );
398
- }
399
- if (
400
- process.env.NODE_ENV !== "production" &&
401
- options?.prefetch !== undefined &&
402
- typeof options.prefetch !== "boolean"
403
- ) {
404
- throw new Error(
405
- `clientUrls() loader() prefetch must be a boolean (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
406
- );
407
- }
408
- const items = use ? runUse(use, "loader use") : [];
409
- rejectItems(items, new Set(["revalidate"]), "loader");
410
- return toPublicItem({
411
- [ITEM_KIND]: true,
412
- type: "loader",
413
- definition,
414
- revalidate: items
415
- .filter(
416
- (item): item is Extract<InternalItem, { type: "revalidate" }> =>
417
- item.type === "revalidate",
418
- )
419
- .map((item) => item.fn),
420
- ...(options?.ssr === false ? { ssr: false as const } : {}),
421
- ...(options?.prefetch === false ? { prefetch: false as const } : {}),
422
- });
423
- };
424
-
425
- const revalidate: ClientUrlHelpers["revalidate"] = (fn) => {
426
- // The build leaves a marker in place of the predicate it moved to the
427
- // server (vite/plugins/client-urls-revalidate-hoist.ts).
428
- if (typeof fn !== "function" && !isHoistedRevalidateRef(fn)) {
429
- throw new Error("clientUrls() revalidate() expects a predicate function");
430
- }
431
- return toPublicItem({
432
- [ITEM_KIND]: true,
433
- type: "revalidate",
434
- fn,
435
- });
436
- };
437
-
438
- const loading: ClientUrlHelpers["loading"] = (component) =>
439
- toPublicItem({ [ITEM_KIND]: true, type: "loading", component });
440
-
441
- const transition: ClientUrlHelpers["transition"] = (config) => {
442
- if (
443
- typeof config !== "object" ||
444
- config === null ||
445
- Array.isArray(config)
446
- ) {
447
- throw new Error(
448
- "clientUrls() transition() expects a configuration object",
449
- );
450
- }
451
- for (const key of Reflect.ownKeys(config)) {
452
- if (!TRANSITION_CONFIG_KEYS.has(key)) {
453
- throw new Error(
454
- `clientUrls() transition() option ${JSON.stringify(String(key))} is not supported`,
455
- );
456
- }
457
- }
458
- if (config.name !== undefined && typeof config.name !== "string") {
459
- throw new Error("clientUrls() transition() name must be a string");
460
- }
461
- if (config.when !== undefined && typeof config.when !== "function") {
462
- throw new TypeError(
463
- process.env.NODE_ENV !== "production"
464
- ? `clientUrls() transition() when must be a function (it runs in the browser), got ${config.when === null ? "null" : typeof config.when}`
465
- : "clientUrls() transition() when must be a function",
466
- );
467
- }
468
- if (
469
- config.viewTransition !== undefined &&
470
- config.viewTransition !== "auto" &&
471
- config.viewTransition !== false
472
- ) {
473
- throw new Error(
474
- 'clientUrls() transition() viewTransition must be "auto" or false',
475
- );
476
- }
477
- for (const key of [
478
- "enter",
479
- "exit",
480
- "update",
481
- "share",
482
- "default",
483
- ] as const) {
484
- const value = config[key];
485
- if (value === undefined || typeof value === "string") continue;
486
- const isStringMap =
487
- typeof value === "object" &&
488
- value !== null &&
489
- !Array.isArray(value) &&
490
- Object.values(value).every((entry) => typeof entry === "string");
491
- if (!isStringMap) {
492
- throw new Error(
493
- `clientUrls() transition() ${key} must be a string or a string map of transition types`,
494
- );
495
- }
496
- }
497
- return toPublicItem({
498
- [ITEM_KIND]: true,
499
- type: "transition",
500
- config: { ...config },
501
- });
502
- };
503
-
504
550
  const intercept: ClientUrlHelpers["intercept"] = (
505
551
  slotName,
506
552
  targetName,
@@ -522,8 +568,9 @@ function createHelpers(): ClientUrlHelpers {
522
568
  );
523
569
  }
524
570
  assertNamedComponent(component, "intercept");
525
- const items = use ? runUse(use, "intercept use") : [];
526
- 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);
527
574
  for (const item of items) {
528
575
  // Intercept loaders run on soft navigations only — a document-render
529
576
  // await can never apply, so accepting the flag would be silently inert.
@@ -607,11 +654,12 @@ function addPathRecord(
607
654
  const routeContext = applyConfig(context, item.use);
608
655
  const options = item.options ? Object.freeze({ ...item.options }) : undefined;
609
656
  // Route-scoped only: transition() is rejected outside path() use callbacks,
610
- // so there is no layout-level inheritance to fold in.
611
- const transitionItem = item.use.find(
612
- (useItem): useItem is Extract<InternalItem, { type: "transition" }> =>
613
- useItem.type === "transition",
614
- );
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
+ }
615
663
  routes.push(
616
664
  Object.freeze({
617
665
  id: `client-route-${routes.length}`,
@@ -702,12 +750,13 @@ function collectIntercepts(
702
750
  let loading: ReactNode | undefined;
703
751
  for (const useItem of item.use) {
704
752
  if (useItem.type === "loader") {
705
- // `prefetch` is not carried: an intercept prefetch defers nothing,
706
- // so the flag is accepted and ignored, as on a urls() intercept().
707
753
  loaders.push(
708
754
  Object.freeze({
709
755
  loader: useItem.definition,
710
756
  revalidate: Object.freeze([...useItem.revalidate]),
757
+ ...(useItem.prefetch === false
758
+ ? { prefetch: false as const }
759
+ : {}),
711
760
  }),
712
761
  );
713
762
  } else if (useItem.type === "loading") {
@@ -768,31 +817,62 @@ export function clientUrls<const TItems extends ClientUrlItems>(
768
817
  collectIntercepts(items, intercepts);
769
818
  validateIntercepts(intercepts, routes);
770
819
 
771
- const routeManifest: Record<string, string> = {};
772
- const staticPrefixes: Record<string, string> = {};
773
- const trailingSlash: Record<string, string> = {};
774
- for (const route of routes) {
775
- routeManifest[route.id] = route.pattern;
776
- staticPrefixes[route.id] = "";
777
- if (route.options?.trailingSlash) {
778
- trailingSlash[route.id] = route.options.trailingSlash;
779
- }
780
- }
781
- const trie = buildRouteTrie(routeManifest, staticPrefixes, trailingSlash);
820
+ const tries = new Map<string, TrieNode>();
782
821
 
783
822
  return Object.freeze({
784
823
  __brand: "client-urls" as const,
785
824
  routes: Object.freeze(routes),
786
825
  intercepts: Object.freeze(intercepts),
787
- match(pathname: string) {
826
+ match(pathname: string, mount = "/", foreign: readonly string[] = []) {
827
+ const key = `${mount} ${foreign}`;
828
+ let trie = tries.get(key);
829
+ if (!trie) {
830
+ tries.set(key, (trie = buildClientUrlsTrie(routes, mount, foreign)));
831
+ }
788
832
  return tryTrieMatch(trie, pathname);
789
833
  },
790
834
  }) as ClientUrlPatterns<ExtractRoutes<TItems>>;
791
835
  }
792
836
 
837
+ /**
838
+ * The router's trie over a group's routes, each pattern joined to the
839
+ * include() mount PATTERN (`/:locale?`, `/:accountId/:projectId`): an
840
+ * absolute pathname matches with the mount's params bound next to the
841
+ * route's, by the server's own precedence (an optional mount param expands
842
+ * like any other). Comparing the pathname with the mount as a literal prefix
843
+ * never matched a parameterized mount.
844
+ *
845
+ * `foreign` are the server's routes outside the group that compete with its
846
+ * routes for a URL (competingPatterns in server-projection.ts): a `/:slug`
847
+ * route under `/:locale?` matches a root `/login` too. Each is a leaf keyed by
848
+ * its own pattern, inserted last so an exact tie goes to it; a match on one is
849
+ * not the group's.
850
+ */
851
+ export function buildClientUrlsTrie(
852
+ routes: readonly {
853
+ readonly id: string;
854
+ readonly pattern: string;
855
+ readonly options?: { readonly trailingSlash?: string };
856
+ }[],
857
+ mount: string,
858
+ foreign: readonly string[] = [],
859
+ ): TrieNode {
860
+ const routeManifest: Record<string, string> = {};
861
+ const trailingSlash: Record<string, string> = {};
862
+ for (const route of routes) {
863
+ routeManifest[route.id] = joinMount(mount, route.pattern);
864
+ if (route.options?.trailingSlash) {
865
+ trailingSlash[route.id] = route.options.trailingSlash;
866
+ }
867
+ }
868
+ for (const pattern of foreign) routeManifest[pattern] = pattern;
869
+ return buildRouteTrie(routeManifest, {}, trailingSlash);
870
+ }
871
+
793
872
  export type {
794
873
  ClientTransitionConfig,
795
874
  ClientUrlBuilder,
875
+ ClientUrlComponent,
796
876
  ClientUrlHelpers,
797
877
  ClientUrlInterceptRecord,
798
878
  ClientUrlItem,
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { startTransition } from "react";
4
+ import { sameMountInstance } from "../router/substitute-pattern-params.js";
4
5
  import type { ClientUrlPatterns } from "./types.js";
5
6
  import type { TransitionWhenFn } from "../types/segments.js";
6
7
 
@@ -13,7 +14,10 @@ export interface ClientUrlNavigationIntent {
13
14
  * screen.
14
15
  */
15
16
  readonly fromFrame: boolean;
16
- /** Destination params from the local trie match (definition-local). */
17
+ /**
18
+ * Destination params: the route's and its include() mount's, the params
19
+ * useParams() reads after the commit.
20
+ */
17
21
  readonly params: Readonly<Record<string, string>>;
18
22
  /** Absolute destination pathname (mount included) and search ("?..." or ""). */
19
23
  readonly pathname: string;
@@ -29,27 +33,27 @@ export interface ClientUrlNavigationIntent {
29
33
 
30
34
  /** What the bridge needs to decide transition({ when }) at the swap. */
31
35
  export interface ClientUrlDestination {
32
- /** Definition-local params of the destination match. */
36
+ /** Destination params, the include() mount's included. */
33
37
  readonly params: Readonly<Record<string, string>>;
34
38
  /** Canonical route name (include prefix applied); undefined when unnamed. */
35
39
  readonly routeName: string | undefined;
36
40
  /** The destination route's own `when`, from its "use client" definition. */
37
41
  readonly when: TransitionWhenFn | undefined;
38
- /**
39
- * Param names the ORIGIN route matched inside the group: the committed
40
- * params minus these are the mount's (include prefix) params.
41
- */
42
- readonly originLocalParamNames: readonly string[];
43
42
  /** `history.state` the navigation will push (ClientUrlNavigationIntent.state). */
44
43
  readonly state: unknown;
45
44
  }
46
45
 
47
46
  interface ActiveClientUrlGroup {
48
47
  readonly definition: ClientUrlPatterns;
49
- /** include() mount prefix the group is rendered under ("/" at root). */
48
+ /**
49
+ * include() mount the group is rendered under ("/" at root): the PATTERN
50
+ * (`/:locale?`), as useMount() returns it.
51
+ */
50
52
  readonly mount: string;
51
53
  /** include() route-name prefix ("" at root) for canonical name composition. */
52
54
  readonly namePrefix: string;
55
+ /** The server's routes that compete with the group's (ClientUrlsRoot). */
56
+ readonly foreign: readonly string[] | undefined;
53
57
  /**
54
58
  * The group route on screen (ClientUrlsRoot's committed routeId), undefined
55
59
  * while an optimistic frame is. Registered at commit.
@@ -90,34 +94,19 @@ export interface ClientUrlNavigationPresentation {
90
94
 
91
95
  let activeGroup: ActiveClientUrlGroup | null = null;
92
96
 
93
- /**
94
- * Strip the include mount prefix from an absolute pathname, yielding the
95
- * definition-local pathname the module trie was built from. Returns null when
96
- * the pathname lies outside the mount — the navigation then targets a server
97
- * route and gets no optimistic presentation. Mirrors joinMount() in
98
- * use-reverse.ts: the bare mount maps to the module index "/".
99
- */
100
- function stripMountPrefix(pathname: string, mount: string): string | null {
101
- if (mount === "" || mount === "/") return pathname;
102
- const normalized = mount.endsWith("/") ? mount.slice(0, -1) : mount;
103
- if (pathname === normalized) return "/";
104
- if (pathname.startsWith(`${normalized}/`)) {
105
- return pathname.slice(normalized.length);
106
- }
107
- return null;
108
- }
109
-
110
97
  export function registerClientUrlGroup(
111
98
  definition: ClientUrlPatterns,
112
99
  mount: string,
113
100
  namePrefix: string,
114
101
  setIntent: (intent: ClientUrlNavigationIntent | null) => void,
115
102
  routeId?: string,
103
+ foreign?: readonly string[],
116
104
  ): () => void {
117
105
  const group: ActiveClientUrlGroup = {
118
106
  definition,
119
107
  mount,
120
108
  namePrefix,
109
+ foreign,
121
110
  routeId,
122
111
  setIntent,
123
112
  };
@@ -138,20 +127,42 @@ export function beginClientUrlNavigation(
138
127
  const group = activeGroup;
139
128
  if (!group) return null;
140
129
 
141
- const localPathname = stripMountPrefix(targetUrl.pathname, group.mount);
142
- if (localPathname === null) return null;
143
-
144
- const match = group.definition.match(localPathname);
130
+ // Not one of the group's routes under its mount pattern, or a server route
131
+ // that wins the URL (a foreign leaf): no optimistic presentation.
132
+ const match = group.definition.match(
133
+ targetUrl.pathname,
134
+ group.mount,
135
+ group.foreign,
136
+ );
145
137
  if (!match || match.redirectTo) return null;
138
+ const record = group.definition.routes.find(
139
+ (candidate) => candidate.id === match.routeKey,
140
+ );
141
+ if (!record) return null;
142
+ // Other mount params (`/en/...` to `/de/...`) are another instance of the
143
+ // group: a frame would render the destination inside the old instance's
144
+ // chrome (the server's layouts above the group, still the old locale's).
145
+ // Such a click leaves the group: a plain navigation. History still holds the
146
+ // committed entry while a frame is on screen, so it is the origin.
147
+ if (group.mount.includes(":")) {
148
+ const origin = group.definition.match(
149
+ window.location.pathname,
150
+ group.mount,
151
+ group.foreign,
152
+ );
153
+ if (
154
+ !origin ||
155
+ !sameMountInstance(group.mount, origin.params, match.params)
156
+ ) {
157
+ return null;
158
+ }
159
+ }
146
160
 
147
161
  // Decline when an intercept would claim this target: compose the matched
148
162
  // record's canonical route name (include namePrefix + local name) and check
149
163
  // it against the current location's intercept target set. Unnamed records
150
164
  // cannot be intercept targets (intercepts target route NAMES).
151
- const record = group.definition.routes.find(
152
- (candidate) => candidate.id === match.routeKey,
153
- );
154
- const canonicalName = record?.name
165
+ const canonicalName = record.name
155
166
  ? group.namePrefix
156
167
  ? `${group.namePrefix}.${record.name}`
157
168
  : record.name
@@ -166,15 +177,11 @@ export function beginClientUrlNavigation(
166
177
  // screen, group.routeId is undefined and every intent swaps.
167
178
  let transitionGatedOff: boolean | undefined;
168
179
  if (decideTransition && match.routeKey !== group.routeId) {
169
- const originLocal = stripMountPrefix(window.location.pathname, group.mount);
170
- const originMatch =
171
- originLocal === null ? null : group.definition.match(originLocal);
172
- const when = record?.transition?.when;
180
+ const when = record.transition?.when;
173
181
  transitionGatedOff = decideTransition({
174
182
  params: match.params,
175
183
  routeName: canonicalName,
176
184
  when: typeof when === "function" ? when : undefined,
177
- originLocalParamNames: Object.keys(originMatch?.params ?? {}),
178
185
  state,
179
186
  });
180
187
  }