@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.
@@ -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
  }
@@ -2,8 +2,10 @@ import * as React from "react";
2
2
  import type { SearchSchemaValue } from "../search-params.js";
3
3
  import { getLoaderLazy } from "../server/loader-registry.js";
4
4
  import {
5
+ getContext,
5
6
  getNamePrefix,
6
7
  getUrlPrefix,
8
+ peekShortCode,
7
9
  type InterceptSelectorContext,
8
10
  } from "../server/context.js";
9
11
  import type {
@@ -21,6 +23,7 @@ import type { AllUseItems } from "../route-types.js";
21
23
  import { urls } from "../urls/urls-function.js";
22
24
  import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
23
25
  import { getClientUrlRevalidator } from "./revalidate-registry.js";
26
+ import { setLayoutLoaderCopies } from "./layout-loader-copies.js";
24
27
  import {
25
28
  ClientUrlsGroupLayout,
26
29
  ClientUrlsInterceptLoading,
@@ -29,9 +32,17 @@ import {
29
32
  ClientUrlsRoot,
30
33
  } from "./client-root.js";
31
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";
32
42
  import type {
33
43
  ClientTransitionConfig,
34
44
  ClientUrlInterceptRecord,
45
+ ClientUrlLoaderRecord,
35
46
  ClientUrlPatterns,
36
47
  ClientUrlRouteRecord,
37
48
  } from "./types.js";
@@ -106,6 +117,11 @@ export interface ClientUrlProjectionRoute {
106
117
  * the registered predicates (revalidate-registry.ts). Absent when no
107
118
  * loader declares one. */
108
119
  readonly loaderRevalidate?: readonly (readonly string[])[];
120
+ /** One per leading loader its layout()s declare
121
+ * (ClientUrlRouteRecord.layoutLoaders): the record's ordinal in the
122
+ * definition. Two routes whose loader at an index carries the same ordinal
123
+ * hold one layout() loader (layout-loader-copies.ts). Absent when none. */
124
+ readonly layoutLoaders?: readonly number[];
109
125
  /** Data-only transition config (never `when`); absent in projections
110
126
  * serialized before transition support. */
111
127
  readonly transition?: Readonly<ClientTransitionConfig>;
@@ -299,7 +315,13 @@ function indicesWhere<T>(
299
315
  function serializeRoute(
300
316
  route: ClientUrlRouteRecord,
301
317
  options: SerializeClientUrlPatternsOptions,
318
+ ordinals: Map<ClientUrlLoaderRecord, number>,
302
319
  ): ClientUrlProjectionRoute {
320
+ // flattenLayout creates a layout()'s records once for every route below it.
321
+ const layoutLoaders = route.layoutLoaders.map((record) => {
322
+ if (!ordinals.has(record)) ordinals.set(record, ordinals.size);
323
+ return ordinals.get(record)!;
324
+ });
303
325
  const loaderIds = route.loaders.map(({ loader }, index) => {
304
326
  if (typeof loader.$$id !== "string" || loader.$$id.length === 0) {
305
327
  throw projectionError(
@@ -355,6 +377,9 @@ function serializeRoute(
355
377
  ),
356
378
  }
357
379
  : {}),
380
+ ...(layoutLoaders.length > 0
381
+ ? { layoutLoaders: Object.freeze(layoutLoaders) }
382
+ : {}),
358
383
  ...(transition ? { transition } : {}),
359
384
  ...(typeof route.transition?.when === "function"
360
385
  ? { transitionWhen: true as const }
@@ -388,10 +413,11 @@ export function serializeClientUrlPatterns(
388
413
  patterns: ClientUrlPatterns,
389
414
  options: SerializeClientUrlPatternsOptions = {},
390
415
  ): ClientUrlProjection {
416
+ const ordinals = new Map<ClientUrlLoaderRecord, number>();
391
417
  return Object.freeze({
392
418
  version: 1 as const,
393
419
  routes: Object.freeze(
394
- patterns.routes.map((route) => serializeRoute(route, options)),
420
+ patterns.routes.map((route) => serializeRoute(route, options, ordinals)),
395
421
  ),
396
422
  intercepts: Object.freeze(
397
423
  (patterns.intercepts ?? []).map(serializeIntercept),
@@ -588,6 +614,100 @@ function materializedPathOptions(
588
614
  };
589
615
  }
590
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
+
591
711
  function materializeRouteItems(
592
712
  reference: ClientUrlDefinitionSource,
593
713
  projection: ClientUrlProjection,
@@ -608,14 +728,70 @@ function materializeRouteItems(
608
728
  // One key per group MOUNT (the include's URL prefix): renderSegments keys
609
729
  // every route segment of the group by it (ResolvedSegment.clientGroup).
610
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
+ };
611
757
 
612
758
  // Helper calls attach to the CURRENT ctx.parent as they execute, so these
613
759
  // builders must run where the items belong: at the module top level for the
614
760
  // flat (no-intercept) shape, or inside the group layout's children callback
615
761
  // when intercepts wrap the group.
616
- const buildRouteItems = (): AllUseItems[] =>
617
- projection.routes.map(
618
- (route) =>
762
+ const buildRouteItems = (): AllUseItems[] => {
763
+ // Each route's shortCode, read before path() takes them: one per route,
764
+ // in order, from the same counter. An evaluation for one route skips the
765
+ // others' entries, not their codes.
766
+ const codes = projection.routes.map((_, ahead) =>
767
+ peekShortCode("route", ahead),
768
+ );
769
+ // A route's use(), recording each layout() loader copy's ids under the
770
+ // other routes of its layout() (layout-loader-copies.ts). path() runs it
771
+ // with the route entry as parent, whose loaders it has created on return.
772
+ const recordingCopies =
773
+ <T>(routeIndex: number, use: () => T) =>
774
+ (): T => {
775
+ const items = use();
776
+ const entry = getContext().getParent()!;
777
+ projection.routes[routeIndex].layoutLoaders?.forEach(
778
+ (ordinal, loaderIndex) =>
779
+ setLayoutLoaderCopies(
780
+ entry.loader[loaderIndex].loader,
781
+ projection.routes.flatMap((other, otherIndex) =>
782
+ otherIndex !== routeIndex &&
783
+ other.layoutLoaders?.[loaderIndex] === ordinal
784
+ ? [
785
+ `${codes[otherIndex]}D${loaderIndex}.${other.loaderIds[loaderIndex]}`,
786
+ ]
787
+ : [],
788
+ ),
789
+ ),
790
+ );
791
+ return items;
792
+ };
793
+ return projection.routes.map(
794
+ (route, routeIndex) =>
619
795
  path(
620
796
  route.pattern,
621
797
  () =>
@@ -623,9 +799,10 @@ function materializeRouteItems(
623
799
  definition: reference as ClientUrlPatterns,
624
800
  routeId: route.id,
625
801
  namePrefix,
802
+ ...foreignProp(),
626
803
  }),
627
804
  materializedPathOptions(route, clientGroup),
628
- () => [
805
+ recordingCopies(routeIndex, () => [
629
806
  ...route.loaderIds.map((id, loaderIndex) => {
630
807
  const revalidators = loaderRevalidators(
631
808
  reference,
@@ -669,9 +846,10 @@ function materializeRouteItems(
669
846
  }),
670
847
  ]
671
848
  : []),
672
- ],
849
+ ]),
673
850
  ) as AllUseItems,
674
851
  );
852
+ };
675
853
 
676
854
  const intercepts = projection.intercepts ?? [];
677
855
  if (intercepts.length === 0) return buildRouteItems();
@@ -694,15 +872,27 @@ function materializeRouteItems(
694
872
  // out-of-group origin the slot's host layout is not in the rendered
695
873
  // tree, so the modal would silently vanish. The declaration contract is
696
874
  // module-local, so materialization synthesizes the origin check the DSL
697
- // deliberately does not expose: activate only when the origin pathname
698
- // is inside this include's mount. This is server-generated code — no
699
- // function crosses the "use client" projection boundary.
700
- const mount = getUrlPrefix() || "/";
701
- const isInGroupOrigin = ({ from }: InterceptSelectorContext): boolean =>
702
- mount === "/"
703
- ? true
704
- : from.url.pathname === mount ||
705
- 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
+ };
706
896
  const slotNames = [...new Set(intercepts.map((record) => record.slotName))];
707
897
  const buildInterceptItems = (): AllUseItems[] =>
708
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
  }
@@ -89,6 +89,13 @@ export function withSegmentHandles(
89
89
  * the position of the first value they replace (the end when they replace
90
90
  * none). New containers throughout: an object a reader was handed (a history
91
91
  * entry's data) is never written to.
92
+ *
93
+ * A segment with no values of a handle takes, for each loader `matched`
94
+ * registers on it that did not run, the values that loader pushed under a
95
+ * segment the page leaves, a slot excepted: a clientUrls() layout() loader the
96
+ * server held on a navigation between two routes of the layout
97
+ * (client-urls/layout-loader-copies.ts), whose pushes the page filed under
98
+ * the route it leaves.
92
99
  */
93
100
  export function mergePartialHandleData(
94
101
  current: HandleData,
@@ -109,13 +116,31 @@ export function mergePartialHandleData(
109
116
  if (isPushedByKey(name)) continue;
110
117
  const byName = pushedByKey(name);
111
118
  const buckets: Record<string, unknown[]> = (out[name] = {});
112
- for (const segmentId of keysOf(current[name], incoming[name])) {
113
- if (!inOrder.has(segmentId)) continue;
119
+ for (const segmentId of segmentOrder) {
114
120
  const run = (by: PushedBy, i: number): string => {
115
121
  const key = `${segmentId}\u0000${by?.[i]}`;
116
122
  return registered.has(key) ? key : segmentId;
117
123
  };
118
- const currentBy = current[byName]?.[segmentId] as PushedBy;
124
+ let currentValues = current[name]?.[segmentId];
125
+ let currentBy = current[byName]?.[segmentId] as PushedBy;
126
+ if (!currentValues) {
127
+ // A loader's values from a segment the page leaves; one that ran for
128
+ // this response replaces them below, like any of its values. Never
129
+ // from a slot (".@"): an intercept's or a parallel's own copy of the
130
+ // loader pushed those.
131
+ const carried: unknown[] = (currentValues = []);
132
+ const carriedBy: (string | null)[] = [];
133
+ for (const from in current[byName]) {
134
+ if (inOrder.has(from) || from.includes(".@")) continue;
135
+ current[byName][from].forEach((_, i, runs) => {
136
+ const key = run(runs as PushedBy, i);
137
+ if (key === segmentId) return;
138
+ carried.push(current[name][from][i]);
139
+ carriedBy.push(runs[i] as string);
140
+ });
141
+ }
142
+ currentBy = carriedBy;
143
+ }
119
144
  const incomingValues = incoming[name]?.[segmentId] ?? [];
120
145
  const incomingBy = incoming[byName]?.[segmentId] as PushedBy;
121
146
  const delivered = new Set(
@@ -124,7 +149,7 @@ export function mergePartialHandleData(
124
149
  const values: unknown[] = [];
125
150
  const by: (string | null)[] = [];
126
151
  let at = -1;
127
- (current[name]?.[segmentId] ?? []).forEach((value, i) => {
152
+ currentValues.forEach((value, i) => {
128
153
  const key = run(currentBy, i);
129
154
  if (delivered.has(key) || ran.has(key)) {
130
155
  if (at < 0) at = values.length;
@@ -54,6 +54,7 @@ import {
54
54
  type RouteSnapshot,
55
55
  } from "./route-snapshot.js";
56
56
  import { resolveNavigation } from "./navigation-snapshot.js";
57
+ import { withLayoutLoaderCopies } from "../client-urls/layout-loader-copies.js";
57
58
  import {
58
59
  partialDeferralMode,
59
60
  planPrefetchDeferral,
@@ -419,9 +420,16 @@ export async function createMatchContextForPartial<TEnv>(
419
420
  )
420
421
  : null);
421
422
 
423
+ // The page's copy of a clientUrls() layout() loader under the route it
424
+ // leaves holds the destination route's (client-urls/layout-loader-copies.ts).
425
+ const held = withLayoutLoaderCopies(
426
+ nav.clientSegmentSet,
427
+ snapshot.manifestEntry,
428
+ );
429
+
422
430
  // Make a mutable copy of clientSegmentSet so we can delete entries
423
431
  // for same-route navigation forcing
424
- const clientSegmentSet = new Set(nav.clientSegmentSet);
432
+ const clientSegmentSet = new Set(held);
425
433
 
426
434
  if (
427
435
  nav.isSameRouteNavigation &&
@@ -481,9 +489,9 @@ export async function createMatchContextForPartial<TEnv>(
481
489
  }),
482
490
  matched,
483
491
  cacheScope,
484
- // As the request listed them, not the copy forced above: see
492
+ // What the page holds, not the copy forced above: see
485
493
  // prefetch-deferral.ts.
486
- nav.clientSegmentSet,
494
+ held,
487
495
  );
488
496
  (handlerContext as InternalHandlerContext<any, TEnv>)._prefetchDeferral =
489
497
  deferral;
@@ -55,6 +55,7 @@ import {
55
55
  buildLoaderErrorContext,
56
56
  } from "./helpers.js";
57
57
  import { applyViewTransitionDefault } from "./view-transition-default.js";
58
+ import { isLayoutLoaderCopy } from "../../client-urls/layout-loader-copies.js";
58
59
  import { getRouterContext } from "../router-context.js";
59
60
  import { observeEvent, observeSegmentHandler } from "../instrument.js";
60
61
  import { runInSegmentTagScope } from "../../cache/cache-tag.js";
@@ -254,7 +255,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
254
255
  component: null,
255
256
  params: ctx.params,
256
257
  loaderId: loader.$$id,
257
- belongsToRoute,
258
+ // A clientUrls() layout() loader's copy is projected onto the
259
+ // route but owned by a layout: it takes a layout's default.
260
+ belongsToRoute: belongsToRoute && !isLayoutLoaderCopy(loader),
258
261
  };
259
262
 
260
263
  return await revalidateHeld({
@@ -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
+ }