@rangojs/router 0.24.3 → 0.24.4

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.
@@ -25,12 +25,15 @@ export declare function ClientUrlsInterceptLoading({ definition, interceptIndex,
25
25
  definition: ClientUrlPatterns;
26
26
  interceptIndex: number;
27
27
  }): ReactNode;
28
- export declare function ClientUrlsRoot({ definition, routeId, namePrefix, }: {
28
+ export declare function ClientUrlsRoot({ definition, routeId, namePrefix, foreign, }: {
29
29
  definition: ClientUrlPatterns;
30
30
  routeId: string;
31
31
  /** include() route-name prefix, injected at materialization for canonical
32
32
  * name composition (intercept-target coordination). */
33
33
  namePrefix?: string;
34
+ /** The server's routes that compete with the group's for a URL
35
+ * (competingPatterns in server-projection.ts). */
36
+ foreign?: readonly string[];
34
37
  }): ReactNode;
35
38
  export declare function ClientUrlsLoading({ definition, routeId, }: {
36
39
  definition: ClientUrlPatterns;
@@ -1,5 +1,27 @@
1
+ import { type TrieNode } from "../router/route-trie-builder.js";
1
2
  import type { ClientUrlBuilder, ClientUrlPatterns } from "./types.js";
2
3
  import type { ExtractRoutes } from "../urls/type-extraction.js";
3
4
  import type { ClientUrlItems } from "./types.js";
4
5
  export declare function clientUrls<const TItems extends ClientUrlItems>(builder: ClientUrlBuilder<TItems>): ClientUrlPatterns<ExtractRoutes<TItems>>;
6
+ /**
7
+ * The router's trie over a group's routes, each pattern joined to the
8
+ * include() mount PATTERN (`/:locale?`, `/:accountId/:projectId`): an
9
+ * absolute pathname matches with the mount's params bound next to the
10
+ * route's, by the server's own precedence (an optional mount param expands
11
+ * like any other). Comparing the pathname with the mount as a literal prefix
12
+ * never matched a parameterized mount.
13
+ *
14
+ * `foreign` are the server's routes outside the group that compete with its
15
+ * routes for a URL (competingPatterns in server-projection.ts): a `/:slug`
16
+ * route under `/:locale?` matches a root `/login` too. Each is a leaf keyed by
17
+ * its own pattern, inserted last so an exact tie goes to it; a match on one is
18
+ * not the group's.
19
+ */
20
+ export declare function buildClientUrlsTrie(routes: readonly {
21
+ readonly id: string;
22
+ readonly pattern: string;
23
+ readonly options?: {
24
+ readonly trailingSlash?: string;
25
+ };
26
+ }[], mount: string, foreign?: readonly string[]): TrieNode;
5
27
  export type { ClientTransitionConfig, ClientUrlBuilder, ClientUrlHelpers, ClientUrlInterceptRecord, ClientUrlItem, ClientUrlItemInput, ClientUrlItems, ClientLayoutFn, ClientPathOptions, ClientUrlLoaderRecord, ClientPathFn, ClientUrlPatterns, ClientUrlRouteRecord, ClientUrlUse, } from "./types.js";
@@ -9,7 +9,10 @@ export interface ClientUrlNavigationIntent {
9
9
  * screen.
10
10
  */
11
11
  readonly fromFrame: boolean;
12
- /** Destination params from the local trie match (definition-local). */
12
+ /**
13
+ * Destination params: the route's and its include() mount's, the params
14
+ * useParams() reads after the commit.
15
+ */
13
16
  readonly params: Readonly<Record<string, string>>;
14
17
  /** Absolute destination pathname (mount included) and search ("?..." or ""). */
15
18
  readonly pathname: string;
@@ -24,17 +27,12 @@ export interface ClientUrlNavigationIntent {
24
27
  }
25
28
  /** What the bridge needs to decide transition({ when }) at the swap. */
26
29
  export interface ClientUrlDestination {
27
- /** Definition-local params of the destination match. */
30
+ /** Destination params, the include() mount's included. */
28
31
  readonly params: Readonly<Record<string, string>>;
29
32
  /** Canonical route name (include prefix applied); undefined when unnamed. */
30
33
  readonly routeName: string | undefined;
31
34
  /** The destination route's own `when`, from its "use client" definition. */
32
35
  readonly when: TransitionWhenFn | undefined;
33
- /**
34
- * Param names the ORIGIN route matched inside the group: the committed
35
- * params minus these are the mount's (include prefix) params.
36
- */
37
- readonly originLocalParamNames: readonly string[];
38
36
  /** `history.state` the navigation will push (ClientUrlNavigationIntent.state). */
39
37
  readonly state: unknown;
40
38
  }
@@ -49,7 +47,7 @@ export interface ClientUrlNavigationPresentation {
49
47
  readonly transitionGatedOff?: boolean;
50
48
  clear(): void;
51
49
  }
52
- export declare function registerClientUrlGroup(definition: ClientUrlPatterns, mount: string, namePrefix: string, setIntent: (intent: ClientUrlNavigationIntent | null) => void, routeId?: string): () => void;
50
+ export declare function registerClientUrlGroup(definition: ClientUrlPatterns, mount: string, namePrefix: string, setIntent: (intent: ClientUrlNavigationIntent | null) => void, routeId?: string, foreign?: readonly string[]): () => void;
53
51
  export declare function beginClientUrlNavigation(targetUrl: URL, signal: AbortSignal,
54
52
  /** Builds the entry state; called only when the group presents the target. */
55
53
  buildState?: () => unknown, decideTransition?: (destination: ClientUrlDestination) => boolean): ClientUrlNavigationPresentation | null;
@@ -1,6 +1,7 @@
1
1
  import type { SearchSchemaValue } from "../search-params.js";
2
2
  import type { TrailingSlashMode } from "../types.js";
3
3
  import type { PartialPrerenderProps, UrlPatterns } from "../urls/pattern-types.js";
4
+ import type { TrieNode } from "../router/route-trie-builder.js";
4
5
  import type { ClientTransitionConfig, ClientUrlPatterns } from "./types.js";
5
6
  export interface ClientUrlReference {
6
7
  readonly $$typeof: symbol;
@@ -101,6 +102,16 @@ export declare function isClientUrlSource(value: unknown): value is ClientUrlDef
101
102
  export declare function setClientUrlProjection(reference: string | ClientUrlReference, projection: ClientUrlProjection): void;
102
103
  export declare function getClientUrlProjection(reference: string | ClientUrlReference): ClientUrlProjection | undefined;
103
104
  export declare function clearClientUrlProjections(): void;
105
+ /**
106
+ * The patterns of the router's routes, outside the group, that a URL of the
107
+ * group matches too: the trie walked in step with the group's (its patterns
108
+ * joined to the mount), static against static, a param against either, a
109
+ * catch-all against everything below. Constraints are not compared, so the
110
+ * list may hold a route no URL of the group reaches: the client trie, which
111
+ * gets the patterns as they are, never lets one win a URL it does not match.
112
+ * `own` are the group's route keys in the router's trie.
113
+ */
114
+ export declare function competingPatterns(router: TrieNode, group: TrieNode, own: ReadonlySet<string>): string[];
104
115
  export declare function materializeClientUrlPatterns(reference: ClientUrlDefinitionSource, projection: ClientUrlProjection): UrlPatterns;
105
116
  /**
106
117
  * Adapt a clientUrls() source for `include()` mounting inside the canonical
@@ -149,6 +149,11 @@ export interface ClientUrlPatterns<TRoutes extends Record<string, any> = Record<
149
149
  readonly intercepts: readonly ClientUrlInterceptRecord[];
150
150
  readonly [CLIENT_URL_PATTERNS_BRAND]: void;
151
151
  readonly _routes?: TRoutes;
152
- match(pathname: string): TrieMatchResult | null;
152
+ /**
153
+ * Match an absolute pathname against the routes joined to `mount`, the
154
+ * include() mount pattern (default "/"); params include the mount's. A
155
+ * `foreign` pattern that wins is a match whose routeKey is that pattern.
156
+ */
157
+ match(pathname: string, mount?: string, foreign?: readonly string[]): TrieMatchResult | null;
153
158
  }
154
159
  export {};
@@ -21,3 +21,19 @@
21
21
  * identical across all three call sites.
22
22
  */
23
23
  export declare function substitutePatternParams(pattern: string, params: Record<string, string | undefined>, routeName: string): string;
24
+ /**
25
+ * Join an include() mount with a mount-relative pattern. The mount is the
26
+ * include PATTERN (`/:locale?`), so the result is a pattern too.
27
+ *
28
+ * `pattern === "/"` is the index of the local module: under a non-root mount
29
+ * it collapses, so `/` under `/blog` becomes `/blog`, not `/blog/`. This
30
+ * matches `ctx.reverse(".index")` and, for a mount without a trailing slash,
31
+ * the route pattern the server registers (applyUrlPrefix in
32
+ * urls/path-helper.ts).
33
+ */
34
+ export declare function joinMount(mount: string, pattern: string): string;
35
+ /**
36
+ * Whether two matches of an include() mount pattern are in the same instance
37
+ * of the include: `/en/...` and `/de/...` under `/:locale` are not.
38
+ */
39
+ export declare function sameMountInstance(mount: string, a: Readonly<Record<string, string>>, b: Readonly<Record<string, string>>): boolean;
@@ -1,5 +1,10 @@
1
1
  import type { Handler } from "../types.js";
2
2
  import type { PathFn, ResponsePathFn, JsonResponsePathFn, TextResponsePathFn } from "./path-helper-types.js";
3
+ /**
4
+ * The key path() registers a route under: its name, or a `$path_` name built
5
+ * from its pattern, behind the include's name prefix.
6
+ */
7
+ export declare function routeKeyOf(namePrefix: string | undefined, pattern: string, name: string | undefined): string;
3
8
  /**
4
9
  * A retained on-demand producer must never render on a live production
5
10
  * request: bundle eviction is skipped for onDemand routes (the producer body
@@ -5929,7 +5929,7 @@ import { resolve } from "node:path";
5929
5929
  // package.json
5930
5930
  var package_default = {
5931
5931
  name: "@rangojs/router",
5932
- version: "0.24.3",
5932
+ version: "0.24.4",
5933
5933
  description: "Django-inspired RSC router with composable URL patterns",
5934
5934
  keywords: [
5935
5935
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.24.3",
3
+ "version": "0.24.4",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -557,6 +557,16 @@ a hard load of the target URL renders the full route.
557
557
  hold; it adds the view-transition animation. Inline `<Suspense>` above each
558
558
  `useLoader` read is still the finer-grained tool when different reads on
559
559
  one route should wait independently.
560
+ - **Parameterized mounts.** A group under `include("/:locale", ...)`,
561
+ `include("/:locale?", ...)` or `include("/:accountId/:projectId", ...)`
562
+ presents at the click like one under a literal mount, and `useParams()` in
563
+ the destination carries the mount's params. A click that changes a mount
564
+ param (`/en/...` to `/de/...`) is another instance of the include: no
565
+ optimistic frame, a plain navigation. The group's intercepts open only from
566
+ one of its routes in the same mount instance. A URL the server routes
567
+ elsewhere (a root route that a group `/:slug` under `/:locale?` also
568
+ matches) never renders as a group route: the server ships the competing
569
+ routes with the group.
560
570
  - **Two parallel loaders with equal latency look "SSR'd" together.** Loaders
561
571
  kick off in parallel, so awaiting one (`ssr: false`) gives
562
572
  same-or-faster siblings time to settle coincidentally. Do not read "it was
@@ -457,20 +457,13 @@ export function createNavigationBridge(
457
457
  ).filter((segment) => segment.clientGroup === undefined),
458
458
  options?.replace ? "replace" : "push",
459
459
  {
460
- to: (from) => {
461
- // Mount (include prefix) params carry over; the origin
462
- // route's own params do not.
463
- const params = { ...from.params };
464
- for (const name of destination.originLocalParamNames) {
465
- delete params[name];
466
- }
467
- return {
468
- url: targetUrl,
469
- params: { ...params, ...destination.params },
470
- routeName: destination.routeName,
471
- state: destination.state,
472
- };
473
- },
460
+ // The destination's params include the mount's.
461
+ to: () => ({
462
+ url: targetUrl,
463
+ params: destination.params,
464
+ routeName: destination.routeName,
465
+ state: destination.state,
466
+ }),
474
467
  extra: destination.when,
475
468
  },
476
469
  ),
@@ -2,7 +2,10 @@
2
2
 
3
3
  import { useCallback } from "react";
4
4
  import type { LocalReverseFunction } from "../../reverse.js";
5
- import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
5
+ import {
6
+ joinMount,
7
+ substitutePatternParams,
8
+ } from "../../router/substitute-pattern-params.js";
6
9
  import { serializeSearchParams } from "../../search-params.js";
7
10
  import { useMount } from "./use-mount.js";
8
11
  import { useParams } from "./use-params.js";
@@ -15,22 +18,6 @@ function getPattern(entry: RouteEntry | undefined): string | undefined {
15
18
  return typeof entry === "string" ? entry : entry.path;
16
19
  }
17
20
 
18
- /**
19
- * Join an include mount prefix with a mount-relative pattern.
20
- *
21
- * `pattern === "/"` is the index of the local module — under a non-root
22
- * mount it must collapse so `/` under `/blog` becomes `/blog`, not
23
- * `/blog/`. This matches `ctx.reverse(".index")` on the server.
24
- */
25
- function joinMount(mount: string, pattern: string): string {
26
- if (pattern === "/") {
27
- if (mount === "" || mount === "/") return "/";
28
- return mount.endsWith("/") ? mount.slice(0, -1) : mount;
29
- }
30
- const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
31
- return normalizedMount + pattern;
32
- }
33
-
34
21
  /**
35
22
  * Mount-aware reverse function for a locally-imported `routes` map.
36
23
  *
@@ -108,17 +108,21 @@ export function ClientUrlsRoot({
108
108
  definition,
109
109
  routeId,
110
110
  namePrefix = "",
111
+ foreign,
111
112
  }: {
112
113
  definition: ClientUrlPatterns;
113
114
  routeId: string;
114
115
  /** include() route-name prefix, injected at materialization for canonical
115
116
  * name composition (intercept-target coordination). */
116
117
  namePrefix?: string;
118
+ /** The server's routes that compete with the group's for a URL
119
+ * (competingPatterns in server-projection.ts). */
120
+ foreign?: readonly string[];
117
121
  }): ReactNode {
118
- // The include() mount prefix this group renders under ("/" at root). The
119
- // module trie is built from definition-LOCAL patterns; navigation strips
120
- // this prefix before matching (see stripMountPrefix in navigation.ts), the
121
- // same way client href()/useMount resolve include-relative URLs.
122
+ // The include() mount pattern this group renders under ("/" at root).
123
+ // Navigation matches an absolute pathname against the definition's
124
+ // patterns joined to it (buildClientUrlsTrie in client-urls.ts), as
125
+ // useReverse joins it.
122
126
  const mount = useMount();
123
127
  const [intent, setIntent] = useState<ClientUrlNavigationIntent | null>(null);
124
128
 
@@ -155,8 +159,9 @@ export function ClientUrlsRoot({
155
159
  namePrefix,
156
160
  setIntent,
157
161
  optimisticRoute ? undefined : routeId,
162
+ foreign,
158
163
  ),
159
- [definition, mount, namePrefix, routeId, optimisticRoute],
164
+ [definition, mount, namePrefix, routeId, optimisticRoute, foreign],
160
165
  );
161
166
 
162
167
  // Pending entries for the destination's loaders: useLoader use()s a Promise
@@ -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";
@@ -768,28 +769,58 @@ export function clientUrls<const TItems extends ClientUrlItems>(
768
769
  collectIntercepts(items, intercepts);
769
770
  validateIntercepts(intercepts, routes);
770
771
 
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);
772
+ const tries = new Map<string, TrieNode>();
782
773
 
783
774
  return Object.freeze({
784
775
  __brand: "client-urls" as const,
785
776
  routes: Object.freeze(routes),
786
777
  intercepts: Object.freeze(intercepts),
787
- match(pathname: string) {
778
+ match(pathname: string, mount = "/", foreign: readonly string[] = []) {
779
+ const key = `${mount} ${foreign}`;
780
+ let trie = tries.get(key);
781
+ if (!trie) {
782
+ tries.set(key, (trie = buildClientUrlsTrie(routes, mount, foreign)));
783
+ }
788
784
  return tryTrieMatch(trie, pathname);
789
785
  },
790
786
  }) as ClientUrlPatterns<ExtractRoutes<TItems>>;
791
787
  }
792
788
 
789
+ /**
790
+ * The router's trie over a group's routes, each pattern joined to the
791
+ * include() mount PATTERN (`/:locale?`, `/:accountId/:projectId`): an
792
+ * absolute pathname matches with the mount's params bound next to the
793
+ * route's, by the server's own precedence (an optional mount param expands
794
+ * like any other). Comparing the pathname with the mount as a literal prefix
795
+ * never matched a parameterized mount.
796
+ *
797
+ * `foreign` are the server's routes outside the group that compete with its
798
+ * routes for a URL (competingPatterns in server-projection.ts): a `/:slug`
799
+ * route under `/:locale?` matches a root `/login` too. Each is a leaf keyed by
800
+ * its own pattern, inserted last so an exact tie goes to it; a match on one is
801
+ * not the group's.
802
+ */
803
+ export function buildClientUrlsTrie(
804
+ routes: readonly {
805
+ readonly id: string;
806
+ readonly pattern: string;
807
+ readonly options?: { readonly trailingSlash?: string };
808
+ }[],
809
+ mount: string,
810
+ foreign: readonly string[] = [],
811
+ ): TrieNode {
812
+ const routeManifest: Record<string, string> = {};
813
+ const trailingSlash: Record<string, string> = {};
814
+ for (const route of routes) {
815
+ routeManifest[route.id] = joinMount(mount, route.pattern);
816
+ if (route.options?.trailingSlash) {
817
+ trailingSlash[route.id] = route.options.trailingSlash;
818
+ }
819
+ }
820
+ for (const pattern of foreign) routeManifest[pattern] = pattern;
821
+ return buildRouteTrie(routeManifest, {}, trailingSlash);
822
+ }
823
+
793
824
  export type {
794
825
  ClientTransitionConfig,
795
826
  ClientUrlBuilder,
@@ -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
  }
@@ -32,6 +32,13 @@ import {
32
32
  ClientUrlsRoot,
33
33
  } from "./client-root.js";
34
34
  import { createClientUrlsWhenRef } from "../transition-when-ref.js";
35
+ import type { TrieLeaf, TrieNode } from "../router/route-trie-builder.js";
36
+ import { tryTrieMatch } from "../router/trie-matching.js";
37
+ import { sameMountInstance } from "../router/substitute-pattern-params.js";
38
+ import { getRouterTrie } from "../route-map-builder.js";
39
+ import { _getRequestContext } from "../server/request-context.js";
40
+ import { routeKeyOf } from "../urls/path-helper.js";
41
+ import { buildClientUrlsTrie } from "./client-urls.js";
35
42
  import type {
36
43
  ClientTransitionConfig,
37
44
  ClientUrlInterceptRecord,
@@ -607,6 +614,100 @@ function materializedPathOptions(
607
614
  };
608
615
  }
609
616
 
617
+ /** A param segment on a walked path; the leaf's `pa` names it. */
618
+ const PARAM = "\0";
619
+
620
+ /**
621
+ * The patterns of the router's routes, outside the group, that a URL of the
622
+ * group matches too: the trie walked in step with the group's (its patterns
623
+ * joined to the mount), static against static, a param against either, a
624
+ * catch-all against everything below. Constraints are not compared, so the
625
+ * list may hold a route no URL of the group reaches: the client trie, which
626
+ * gets the patterns as they are, never lets one win a URL it does not match.
627
+ * `own` are the group's route keys in the router's trie.
628
+ */
629
+ export function competingPatterns(
630
+ router: TrieNode,
631
+ group: TrieNode,
632
+ own: ReadonlySet<string>,
633
+ ): string[] {
634
+ const found = new Set<string>();
635
+ const add = (leaf: TrieLeaf, path: readonly string[], tail = ""): void => {
636
+ if (own.has(leaf.n)) return;
637
+ let index = 0;
638
+ const pattern = path.map((piece) => {
639
+ if (piece[0] !== PARAM) return piece;
640
+ const name = leaf.pa?.[index++] ?? `p${index}`;
641
+ const allowed = leaf.cv?.[name];
642
+ return `/:${name}${allowed ? `(${allowed.join("|")})` : ""}${piece.slice(1)}`;
643
+ });
644
+ found.add(pattern.join("") + tail || "/");
645
+ };
646
+ const catchAll = (node: TrieNode, path: readonly string[]): void => {
647
+ const leaf = node.w!;
648
+ add(
649
+ leaf,
650
+ path,
651
+ leaf.pn === "*" ? "/*" : `/:${leaf.pn}${leaf.w1 ? "+" : "*"}`,
652
+ );
653
+ };
654
+ const everything = (node: TrieNode, path: readonly string[]): void => {
655
+ if (node.r) add(node.r, path);
656
+ if (node.w) catchAll(node, path);
657
+ for (const [key, child] of Object.entries(node.s ?? {})) {
658
+ everything(child, [...path, `/${key}`]);
659
+ }
660
+ if (node.p) everything(node.p.c, [...path, PARAM]);
661
+ for (const [suffix, child] of Object.entries(node.xp ?? {})) {
662
+ everything(child.c, [...path, PARAM + suffix]);
663
+ }
664
+ };
665
+ // Every child of the group's node that a segment `matches` lets through.
666
+ const groupSteps = (
667
+ node: TrieNode,
668
+ matches: (key: string) => boolean,
669
+ ): TrieNode[] => [
670
+ ...Object.entries(node.s ?? {}).flatMap(([key, child]) =>
671
+ matches(key) ? [child] : [],
672
+ ),
673
+ ...(node.p ? [node.p.c] : []),
674
+ ...Object.values(node.xp ?? {}).map((child) => child.c),
675
+ ];
676
+ const walk = (g: TrieNode, r: TrieNode, path: readonly string[]): void => {
677
+ if (g.w) return everything(r, path);
678
+ if (r.r && g.r) add(r.r, path);
679
+ if (r.w && (g.r || g.s || g.p || g.xp)) catchAll(r, path);
680
+ for (const [key, child] of Object.entries(r.s ?? {})) {
681
+ const next = [...path, `/${key}`];
682
+ if (g.s?.[key]) walk(g.s[key], child, next);
683
+ if (g.p) walk(g.p.c, child, next);
684
+ for (const [suffix, gx] of Object.entries(g.xp ?? {})) {
685
+ if (key.endsWith(suffix)) walk(gx.c, child, next);
686
+ }
687
+ }
688
+ if (r.p) {
689
+ for (const step of groupSteps(g, () => true)) {
690
+ walk(step, r.p.c, [...path, PARAM]);
691
+ }
692
+ }
693
+ for (const [suffix, child] of Object.entries(r.xp ?? {})) {
694
+ for (const step of groupSteps(g, (key) => key.endsWith(suffix))) {
695
+ walk(step, child.c, [...path, PARAM + suffix]);
696
+ }
697
+ }
698
+ };
699
+ walk(group, router, []);
700
+ return [...found];
701
+ }
702
+
703
+ /** competingPatterns per router trie, per group (name prefix and mount). */
704
+ const COMPETING = new WeakMap<TrieNode, Map<string, readonly string[]>>();
705
+
706
+ function currentRouterTrie(): TrieNode | undefined {
707
+ const routerId = _getRequestContext()?._routerId;
708
+ return routerId ? getRouterTrie(routerId) : undefined;
709
+ }
710
+
610
711
  function materializeRouteItems(
611
712
  reference: ClientUrlDefinitionSource,
612
713
  projection: ClientUrlProjection,
@@ -627,6 +728,32 @@ function materializeRouteItems(
627
728
  // One key per group MOUNT (the include's URL prefix): renderSegments keys
628
729
  // every route segment of the group by it (ResolvedSegment.clientGroup).
629
730
  const clientGroup = getUrlPrefix() || "/";
731
+ // The group's routes as the router's trie names them.
732
+ const own = new Set(
733
+ projection.routes.map((route) =>
734
+ routeKeyOf(namePrefix, route.pattern, route.name ?? undefined),
735
+ ),
736
+ );
737
+ // ClientUrlsRoot's `foreign`: the router's routes that compete with the
738
+ // group's for a URL, so the browser presents only what the server routes
739
+ // here (a `/:slug` route under `/:locale?` would claim a root `/login`).
740
+ const foreignProp = (): { foreign?: readonly string[] } => {
741
+ const router = currentRouterTrie();
742
+ if (!router) return {};
743
+ let byGroup = COMPETING.get(router);
744
+ if (!byGroup) COMPETING.set(router, (byGroup = new Map()));
745
+ const key = `${namePrefix} ${clientGroup}`;
746
+ let foreign = byGroup.get(key);
747
+ if (!foreign) {
748
+ foreign = competingPatterns(
749
+ router,
750
+ buildClientUrlsTrie(projection.routes, clientGroup),
751
+ own,
752
+ );
753
+ byGroup.set(key, foreign);
754
+ }
755
+ return foreign.length ? { foreign } : {};
756
+ };
630
757
 
631
758
  // Helper calls attach to the CURRENT ctx.parent as they execute, so these
632
759
  // builders must run where the items belong: at the module top level for the
@@ -672,6 +799,7 @@ function materializeRouteItems(
672
799
  definition: reference as ClientUrlPatterns,
673
800
  routeId: route.id,
674
801
  namePrefix,
802
+ ...foreignProp(),
675
803
  }),
676
804
  materializedPathOptions(route, clientGroup),
677
805
  recordingCopies(routeIndex, () => [
@@ -744,15 +872,27 @@ function materializeRouteItems(
744
872
  // out-of-group origin the slot's host layout is not in the rendered
745
873
  // tree, so the modal would silently vanish. The declaration contract is
746
874
  // module-local, so materialization synthesizes the origin check the DSL
747
- // deliberately does not expose: activate only when the origin pathname
748
- // is inside this include's mount. This is server-generated code — no
749
- // function crosses the "use client" projection boundary.
750
- const mount = getUrlPrefix() || "/";
751
- const isInGroupOrigin = ({ from }: InterceptSelectorContext): boolean =>
752
- mount === "/"
753
- ? true
754
- : from.url.pathname === mount ||
755
- from.url.pathname.startsWith(`${mount}/`);
875
+ // deliberately does not expose: activate only when the origin is one of
876
+ // this group's routes, in the target's mount instance. The router
877
+ // matched the origin already: its route name, or for an unnamed one its
878
+ // key in the router's trie, says whether it is the group's (a URL
879
+ // prefix cannot: a `/:locale?` mount prefixes every path), and its
880
+ // params whether another locale is another instance, a full page
881
+ // (beginClientUrlNavigation in navigation.ts leaves the group for it
882
+ // too). This is server-generated code — no function crosses the
883
+ // "use client" projection boundary.
884
+ const isInGroupOrigin = ({ from, to }: InterceptSelectorContext): boolean => {
885
+ if (clientGroup === "/") return true;
886
+ const router = currentRouterTrie();
887
+ const key =
888
+ from.routeName ??
889
+ (router && tryTrieMatch(router, from.url.pathname)?.routeKey);
890
+ return (
891
+ !!key &&
892
+ own.has(key) &&
893
+ sameMountInstance(clientGroup, from.params, to.params)
894
+ );
895
+ };
756
896
  const slotNames = [...new Set(intercepts.map((record) => record.slotName))];
757
897
  const buildInterceptItems = (): AllUseItems[] =>
758
898
  intercepts.map(
@@ -215,5 +215,14 @@ export interface ClientUrlPatterns<
215
215
  readonly intercepts: readonly ClientUrlInterceptRecord[];
216
216
  readonly [CLIENT_URL_PATTERNS_BRAND]: void;
217
217
  readonly _routes?: TRoutes;
218
- match(pathname: string): TrieMatchResult | null;
218
+ /**
219
+ * Match an absolute pathname against the routes joined to `mount`, the
220
+ * include() mount pattern (default "/"); params include the mount's. A
221
+ * `foreign` pattern that wins is a match whose routeKey is that pattern.
222
+ */
223
+ match(
224
+ pathname: string,
225
+ mount?: string,
226
+ foreign?: readonly string[],
227
+ ): TrieMatchResult | null;
219
228
  }
@@ -73,3 +73,37 @@ export function substitutePatternParams(
73
73
  if (hasTrailingSlash && !result.endsWith("/")) result += "/";
74
74
  return result;
75
75
  }
76
+
77
+ /**
78
+ * Join an include() mount with a mount-relative pattern. The mount is the
79
+ * include PATTERN (`/:locale?`), so the result is a pattern too.
80
+ *
81
+ * `pattern === "/"` is the index of the local module: under a non-root mount
82
+ * it collapses, so `/` under `/blog` becomes `/blog`, not `/blog/`. This
83
+ * matches `ctx.reverse(".index")` and, for a mount without a trailing slash,
84
+ * the route pattern the server registers (applyUrlPrefix in
85
+ * urls/path-helper.ts).
86
+ */
87
+ export function joinMount(mount: string, pattern: string): string {
88
+ if (pattern === "/") {
89
+ if (mount === "" || mount === "/") return "/";
90
+ return mount.endsWith("/") ? mount.slice(0, -1) : mount;
91
+ }
92
+ const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
93
+ return normalizedMount + pattern;
94
+ }
95
+
96
+ /**
97
+ * Whether two matches of an include() mount pattern are in the same instance
98
+ * of the include: `/en/...` and `/de/...` under `/:locale` are not.
99
+ */
100
+ export function sameMountInstance(
101
+ mount: string,
102
+ a: Readonly<Record<string, string>>,
103
+ b: Readonly<Record<string, string>>,
104
+ ): boolean {
105
+ return (
106
+ substitutePatternParams(mount, a, mount) ===
107
+ substitutePatternParams(mount, b, mount)
108
+ );
109
+ }
@@ -54,6 +54,21 @@ function applyNamePrefix(prefix: string | undefined, name: string): string {
54
54
  return `${prefix}.${name}`;
55
55
  }
56
56
 
57
+ /**
58
+ * The key path() registers a route under: its name, or a `$path_` name built
59
+ * from its pattern, behind the include's name prefix.
60
+ */
61
+ export function routeKeyOf(
62
+ namePrefix: string | undefined,
63
+ pattern: string,
64
+ name: string | undefined,
65
+ ): string {
66
+ return applyNamePrefix(
67
+ namePrefix,
68
+ name || `$path_${pattern.replace(/[/:*?]/g, "_")}`,
69
+ );
70
+ }
71
+
57
72
  function resolveResponseType(
58
73
  options: PathOptions | undefined,
59
74
  ): string | undefined {
@@ -143,12 +158,10 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
143
158
 
144
159
  const prefixedPattern = applyUrlPrefix(urlPrefix, pattern);
145
160
 
146
- const localName =
147
- options?.name || `$path_${pattern.replace(/[/:*?]/g, "_")}`;
148
161
  if (options?.name) {
149
162
  validateUserRouteName(options.name);
150
163
  }
151
- const routeName = applyNamePrefix(namePrefix, localName);
164
+ const routeName = routeKeyOf(namePrefix, pattern, options?.name);
152
165
 
153
166
  const namespace = `${ctx.namespace}.${store.getNextIndex("route")}.${routeName}`;
154
167