@rangojs/router 0.24.2 → 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";
@@ -0,0 +1,11 @@
1
+ import type { EntryData } from "../server/context.js";
2
+ import type { LoaderDefinition } from "../types.js";
3
+ export declare function setLayoutLoaderCopies(stub: LoaderDefinition<any>, ids: readonly string[]): void;
4
+ /** True for the materialized stub of a clientUrls() layout() loader copy. */
5
+ export declare function isLayoutLoaderCopy(stub: LoaderDefinition<any>): boolean;
6
+ /**
7
+ * `held` with the id of each of `route`'s layout() loader copies whose
8
+ * loader the page holds under another route of the layout; `held` itself
9
+ * when there is none.
10
+ */
11
+ export declare function withLayoutLoaderCopies(held: ReadonlySet<string>, route: EntryData): ReadonlySet<string>;
@@ -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;
@@ -42,6 +43,11 @@ export interface ClientUrlProjectionRoute {
42
43
  * the registered predicates (revalidate-registry.ts). Absent when no
43
44
  * loader declares one. */
44
45
  readonly loaderRevalidate?: readonly (readonly string[])[];
46
+ /** One per leading loader its layout()s declare
47
+ * (ClientUrlRouteRecord.layoutLoaders): the record's ordinal in the
48
+ * definition. Two routes whose loader at an index carries the same ordinal
49
+ * hold one layout() loader (layout-loader-copies.ts). Absent when none. */
50
+ readonly layoutLoaders?: readonly number[];
45
51
  /** Data-only transition config (never `when`); absent in projections
46
52
  * serialized before transition support. */
47
53
  readonly transition?: Readonly<ClientTransitionConfig>;
@@ -96,6 +102,16 @@ export declare function isClientUrlSource(value: unknown): value is ClientUrlDef
96
102
  export declare function setClientUrlProjection(reference: string | ClientUrlReference, projection: ClientUrlProjection): void;
97
103
  export declare function getClientUrlProjection(reference: string | ClientUrlReference): ClientUrlProjection | undefined;
98
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[];
99
115
  export declare function materializeClientUrlPatterns(reference: ClientUrlDefinitionSource, projection: ClientUrlProjection): UrlPatterns;
100
116
  /**
101
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 {};
@@ -27,5 +27,12 @@ export declare function withSegmentHandles(target: HandleData, source: HandleDat
27
27
  * the position of the first value they replace (the end when they replace
28
28
  * none). New containers throughout: an object a reader was handed (a history
29
29
  * entry's data) is never written to.
30
+ *
31
+ * A segment with no values of a handle takes, for each loader `matched`
32
+ * registers on it that did not run, the values that loader pushed under a
33
+ * segment the page leaves, a slot excepted: a clientUrls() layout() loader the
34
+ * server held on a navigation between two routes of the layout
35
+ * (client-urls/layout-loader-copies.ts), whose pushes the page filed under
36
+ * the route it leaves.
30
37
  */
31
38
  export declare function mergePartialHandleData(current: HandleData, incoming: HandleData, segmentOrder: readonly string[], matched: readonly string[], resolvedIds: readonly string[] | undefined): HandleData;
@@ -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;
@@ -353,6 +353,14 @@ interface HelperContext {
353
353
  includeScope?: string;
354
354
  }
355
355
  export declare const RangoContext: AsyncLocalStorage<HelperContext>;
356
+ type ShortCodeType = "layout" | "parallel" | "route" | "loader" | "cache";
357
+ /**
358
+ * The shortCode the `ahead`-th next `getShortCode(type)` call in this DSL
359
+ * context returns, without taking it. clientUrls() materialization reads the
360
+ * code of every group route this way, including the routes an evaluation for
361
+ * another route skips (client-urls/layout-loader-copies.ts).
362
+ */
363
+ export declare function peekShortCode(type: ShortCodeType, ahead: number): string;
356
364
  export declare const getContext: () => {
357
365
  context: AsyncLocalStorage<HelperContext>;
358
366
  getStore: () => HelperContext;
@@ -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.2",
5932
+ version: "0.24.4",
5933
5933
  description: "Django-inspired RSC router with composable URL patterns",
5934
5934
  keywords: [
5935
5935
  "react",
@@ -11034,7 +11034,11 @@ function serializeTransition(route2) {
11034
11034
  function indicesWhere(items, pred) {
11035
11035
  return items.flatMap((item, index) => pred(item) ? [index] : []);
11036
11036
  }
11037
- function serializeRoute(route2, options) {
11037
+ function serializeRoute(route2, options, ordinals) {
11038
+ const layoutLoaders = route2.layoutLoaders.map((record) => {
11039
+ if (!ordinals.has(record)) ordinals.set(record, ordinals.size);
11040
+ return ordinals.get(record);
11041
+ });
11038
11042
  const loaderIds = route2.loaders.map(({ loader: loader2 }, index) => {
11039
11043
  if (typeof loader2.$$id !== "string" || loader2.$$id.length === 0) {
11040
11044
  throw projectionError(
@@ -11078,6 +11082,7 @@ function serializeRoute(route2, options) {
11078
11082
  loaderRevalidate.map((keys) => Object.freeze(keys))
11079
11083
  )
11080
11084
  } : {},
11085
+ ...layoutLoaders.length > 0 ? { layoutLoaders: Object.freeze(layoutLoaders) } : {},
11081
11086
  ...transition2 ? { transition: transition2 } : {},
11082
11087
  ...typeof route2.transition?.when === "function" ? { transitionWhen: true } : {}
11083
11088
  });
@@ -11099,10 +11104,11 @@ function serializeIntercept(record, index) {
11099
11104
  });
11100
11105
  }
11101
11106
  function serializeClientUrlPatterns(patterns, options = {}) {
11107
+ const ordinals = /* @__PURE__ */ new Map();
11102
11108
  return Object.freeze({
11103
11109
  version: 1,
11104
11110
  routes: Object.freeze(
11105
- patterns.routes.map((route2) => serializeRoute(route2, options))
11111
+ patterns.routes.map((route2) => serializeRoute(route2, options, ordinals))
11106
11112
  ),
11107
11113
  intercepts: Object.freeze(
11108
11114
  (patterns.intercepts ?? []).map(serializeIntercept)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -278,6 +278,18 @@ yet (a known gap, left for a follow-up). The build still checks those
278
278
  predicates like any other, so one that captures a binding of the `"use client"`
279
279
  module fails the build although nothing runs it: import it, or drop it.
280
280
 
281
+ A group `layout()` loader revalidates as a server layout's loader does. With no
282
+ `revalidate()` a navigation keeps it, whichever route of the layout it goes to
283
+ and whatever params or search change (a same-route param navigation too), and
284
+ an action re-runs it. Its `revalidate()` decides on every navigation and
285
+ action, with that default as `defaultShouldRevalidate`: a shop layout's
286
+ `loader(CartLoader, () => [revalidate(({ isAction }) => isAction(CartActions))])`
287
+ runs on the document request and then only on cart actions, and the page
288
+ commits without waiting for it. A navigation into the layout runs it. A layout
289
+ loader that reads the route's params needs a rule that re-runs it:
290
+ `revalidate(({ currentParams, nextParams, defaultShouldRevalidate }) =>
291
+ currentParams.slug !== nextParams.slug ? true : defaultShouldRevalidate)`.
292
+
281
293
  Two scars worth copying:
282
294
 
283
295
  - A blunt `() => false` keeps serving the OLD product on product→product
@@ -455,7 +467,11 @@ makes the first flush, and `ssr: false` guarantees the loader's data does. It
455
467
  does not cover the client component's module: the first document a build's
456
468
  fresh server renders can still be waiting for it. The dev server matches a
457
469
  build here: when the shell is ready, it waits for the client components the
458
- payload has named and that are still loading, and for nothing else.
470
+ payload has named and that are still loading, and for nothing else. On a
471
+ document with an `ssr: false` loader, a part React finishes a moment after the
472
+ shell, such as a `useActionState` form (React suspends it once to encode its
473
+ action), is in the first flush too: the router gives React up to three more
474
+ turns before it, and none when the document is already complete.
459
475
 
460
476
  ## `prefetch: false` — keep a loader out of prefetches
461
477
 
@@ -477,10 +493,16 @@ too, `useOutlet().pending` ends with that commit, and one follow-up request
477
493
  own `<Suspense>` to show the page and that fallback at once; with no boundary
478
494
  React holds the page being left until the fill answers, as it would with no
479
495
  prefetch. Same rules as on a server route (`/loader` → "`prefetch: false`"):
480
- only a segment new to the page is deferred, so a same-route click
481
- (`/product/a` to `/product/b`) prefetches the loader as usual.
482
-
483
- - On a `layout()` of the group the flag applies to every route below it.
496
+ the flag skips a loader in a prefetch only when the loader is new to the page.
497
+ A loader already on screen is never deferred: if its revalidation (its
498
+ `revalidate()` or the default) re-runs it, it runs in the prefetch with the
499
+ rest of the destination; otherwise it is held. A same-route click
500
+ (`/product/a` to `/product/b`) is the familiar case.
501
+
502
+ - A group `layout()` loader is on screen on every route of its layout: a
503
+ prefetch between two of them defers nothing for it (its `revalidate()`
504
+ decides, and with no rule it is held), and a prefetch that enters the
505
+ layout defers it.
484
506
  - A flagged `loader()` on a server layout above the group is deferred while
485
507
  that layout is new to the page (entering the group from outside).
486
508
  - `loading()` in `clientUrls()` takes no options, and a flagged `loading()`
@@ -529,11 +551,22 @@ a hard load of the target URL renders the full route.
529
551
  `loading()` does not re-flash there. Across different routes, a `layout()`
530
552
  keeps its instance while the navigation stays under it, and its own
531
553
  `layout()` loaders keep their data on the optimistic frame (a cart count does
532
- not suspend); only the destination's loaders and those of layouts it mounts
533
- anew wait for the server. `transition()` is not needed for the
554
+ not suspend), and in the canonical commit when their `revalidate()` holds
555
+ them; only the destination's loaders and those of layouts it mounts anew
556
+ wait for the server. `transition()` is not needed for the
534
557
  hold; it adds the view-transition animation. Inline `<Suspense>` above each
535
558
  `useLoader` read is still the finer-grained tool when different reads on
536
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.
537
570
  - **Two parallel loaders with equal latency look "SSR'd" together.** Loaders
538
571
  kick off in parallel, so awaiting one (`ssr: false`) gives
539
572
  same-or-faster siblings time to settle coincidentally. Do not read "it was
@@ -1128,10 +1128,13 @@ What happens:
1128
1128
  - A **prefetch** of the route does not run the loader and does not read its
1129
1129
  `cache()`; the payload marks it deferred. The rest of the page prefetches as
1130
1130
  usual.
1131
- - Only on a route the browser does not show yet. A loader the page already
1132
- holds, on a same-route navigation such as `/product/a` to `/product/b`, runs
1133
- in the prefetch when it revalidates, exactly as without the flag, and the
1134
- click sends no second request. The rule: a click that adopts a prefetch is
1131
+ - Only on a loader new to the page. A loader already on screen is never
1132
+ deferred: if its revalidation (its `revalidate()` or the default) re-runs
1133
+ it, it runs in the prefetch with the rest of the destination, exactly as
1134
+ without the flag, and the click sends no second request; otherwise it is
1135
+ held. The familiar case is a same-route navigation such as `/product/a` to
1136
+ `/product/b`; a `clientUrls()` group `layout()` loader is on screen on every
1137
+ route of its layout the same way. The rule: a click that adopts a prefetch is
1135
1138
  never worse than the same click with no prefetch at all (one exception: a
1136
1139
  click before its prefetch has answered, under "The limits"), and content on
1137
1140
  screen is never swapped for a fallback because of the flag. For the same
@@ -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
  *
@@ -87,6 +87,19 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
87
87
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
88
88
 
89
89
  const diffSet = new Set(diff);
90
+ // A clientUrls() layout() loader the server held on a navigation between
91
+ // two routes of the layout (client-urls/layout-loader-copies.ts): matched
92
+ // names it under the destination route, the page holds it under the group
93
+ // route it leaves. Its copy keeps its data under the new id.
94
+ const origin = input.cachedSegments.find((s) => s.clientGroup);
95
+ const destination =
96
+ origin &&
97
+ input.serverSegments.find((s) => s.clientGroup === origin.clientGroup);
98
+ const carried = (id: string) => {
99
+ const copy =
100
+ destination && cachedSegments.get(id.replace(destination.id, origin.id));
101
+ return copy && { ...copy, id };
102
+ };
90
103
  if (IS_BROWSER_DEBUG) {
91
104
  debugLog(
92
105
  `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
@@ -102,7 +115,7 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
102
115
  const segments = matched
103
116
  .map((segId: string) => {
104
117
  const fromServer = serverSegments.get(segId);
105
- const fromCache = cachedSegments.get(segId);
118
+ let fromCache = cachedSegments.get(segId);
106
119
 
107
120
  if (fromServer) {
108
121
  const inDiff = diffSet.has(segId);
@@ -179,6 +192,7 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
179
192
  }
180
193
 
181
194
  // Fall back to cached segment (server expects client to already have it)
195
+ fromCache ??= carried(segId);
182
196
  if (!fromCache) {
183
197
  if (actor === "action") {
184
198
  console.error(`[Browser] MISSING SEGMENT: ${segId} not in cache!`);
@@ -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,
@@ -0,0 +1,56 @@
1
+ import type { EntryData } from "../server/context.js";
2
+ import type { LoaderDefinition } from "../types.js";
3
+
4
+ /**
5
+ * A clientUrls() layout()'s loaders are copied into every route record below
6
+ * it (client-urls.ts applyConfig), and a loader segment's id names its route
7
+ * (`${routeShortCode}D${index}.${$$id}`). On a navigation between two of the
8
+ * layout's routes the destination's copy was a segment the client lacked, so
9
+ * it ran whatever its revalidate() said.
10
+ *
11
+ * Materialization (server-projection.ts) records, for each copy, the ids the
12
+ * same layout() loader has under the group's other routes of that layout. A
13
+ * request whose page holds one of them holds the destination's copy: the
14
+ * loader's revalidate() decides, as on a param navigation, and a held copy is
15
+ * left out of the response. The browser carries the page's copy to the id
16
+ * `matched` names (browser/segment-reconciler.ts) with its handle pushes
17
+ * (handles/pushed-by.ts).
18
+ *
19
+ * A copy takes a server layout's revalidation default, not its route's: held
20
+ * on a navigation, params or search changes included, and re-run on an action
21
+ * (isLayoutLoaderCopy, segment-resolution/revalidation.ts).
22
+ *
23
+ * Keyed by the materialized loader stub, created once per evaluation of the
24
+ * route: the ids depend on the route's shortCode and its siblings'.
25
+ */
26
+ const copies = new WeakMap<LoaderDefinition<any>, readonly string[]>();
27
+
28
+ export function setLayoutLoaderCopies(
29
+ stub: LoaderDefinition<any>,
30
+ ids: readonly string[],
31
+ ): void {
32
+ copies.set(stub, ids);
33
+ }
34
+
35
+ /** True for the materialized stub of a clientUrls() layout() loader copy. */
36
+ export function isLayoutLoaderCopy(stub: LoaderDefinition<any>): boolean {
37
+ return copies.has(stub);
38
+ }
39
+
40
+ /**
41
+ * `held` with the id of each of `route`'s layout() loader copies whose
42
+ * loader the page holds under another route of the layout; `held` itself
43
+ * when there is none.
44
+ */
45
+ export function withLayoutLoaderCopies(
46
+ held: ReadonlySet<string>,
47
+ route: EntryData,
48
+ ): ReadonlySet<string> {
49
+ let out: Set<string> | undefined;
50
+ route.loader?.forEach(({ loader }, index) => {
51
+ if (copies.get(loader)?.some((id) => held.has(id))) {
52
+ (out ??= new Set(held)).add(`${route.shortCode}D${index}.${loader.$$id}`);
53
+ }
54
+ });
55
+ return out ?? held;
56
+ }