@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.
- package/dist/types/client-urls/client-root.d.ts +4 -1
- package/dist/types/client-urls/client-urls.d.ts +22 -0
- package/dist/types/client-urls/layout-loader-copies.d.ts +11 -0
- package/dist/types/client-urls/navigation.d.ts +6 -8
- package/dist/types/client-urls/server-projection.d.ts +16 -0
- package/dist/types/client-urls/types.d.ts +6 -1
- package/dist/types/handles/pushed-by.d.ts +7 -0
- package/dist/types/router/substitute-pattern-params.d.ts +16 -0
- package/dist/types/server/context.d.ts +8 -0
- package/dist/types/urls/path-helper.d.ts +5 -0
- package/dist/vite/index.js +9 -3
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +40 -7
- package/skills/loader/SKILL.md +7 -4
- package/src/browser/navigation-bridge.ts +7 -14
- package/src/browser/react/use-reverse.ts +4 -17
- package/src/browser/segment-reconciler.ts +15 -1
- package/src/client-urls/client-root.tsx +10 -5
- package/src/client-urls/client-urls.ts +44 -13
- package/src/client-urls/layout-loader-copies.ts +56 -0
- package/src/client-urls/navigation.ts +45 -38
- package/src/client-urls/server-projection.ts +205 -15
- package/src/client-urls/types.ts +10 -1
- package/src/handles/pushed-by.ts +29 -4
- package/src/router/match-api.ts +11 -3
- package/src/router/segment-resolution/revalidation.ts +4 -1
- package/src/router/substitute-pattern-params.ts +34 -0
- package/src/server/context.ts +48 -23
- package/src/ssr/index.tsx +63 -7
- package/src/urls/path-helper.ts +16 -3
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
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
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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(
|
package/src/client-urls/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/handles/pushed-by.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/router/match-api.ts
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
492
|
+
// What the page holds, not the copy forced above: see
|
|
485
493
|
// prefetch-deferral.ts.
|
|
486
|
-
|
|
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
|
-
|
|
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
|
+
}
|