@rangojs/router 0.0.0-experimental.63 → 0.0.0-experimental.64
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/vite/index.js +1 -1
- package/package.json +1 -1
- package/src/client.tsx +2 -56
- package/src/route-definition/dsl-helpers.ts +5 -1
- package/src/route-definition/helpers-types.ts +4 -1
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/match-api.ts +124 -183
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/segment-resolution/fresh.ts +37 -0
- package/src/router/segment-resolution/revalidation.ts +43 -0
- package/src/router.ts +4 -0
- package/src/rsc/handler.ts +456 -373
- package/src/rsc/ssr-setup.ts +1 -1
- package/src/server/request-context.ts +7 -0
- package/src/urls/path-helper-types.ts +4 -1
- package/src/use-loader.tsx +73 -4
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route Snapshot
|
|
3
|
+
*
|
|
4
|
+
* Pure data type representing the fully-resolved state of a single route match.
|
|
5
|
+
* Consolidates the duplicated findMatch + loadManifest + collectRouteMiddleware +
|
|
6
|
+
* cacheScope derivation that previously lived separately in preview-match.ts
|
|
7
|
+
* and match-api.ts.
|
|
8
|
+
*
|
|
9
|
+
* resolveRoute() is the factory: given a pathname and dependencies, it returns
|
|
10
|
+
* a RouteSnapshot (or redirect/null). Consumers (createMatchContextForFull,
|
|
11
|
+
* createMatchContextForPartial, previewMatch) read snapshot fields instead of
|
|
12
|
+
* re-deriving them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CacheScope } from "../cache/cache-scope.js";
|
|
16
|
+
import { createCacheScope } from "../cache/cache-scope.js";
|
|
17
|
+
import type { EntryData, MetricsStore } from "../server/context.js";
|
|
18
|
+
import { loadManifest } from "./manifest.js";
|
|
19
|
+
import { collectRouteMiddleware } from "./middleware.js";
|
|
20
|
+
import type { CollectedMiddleware } from "./middleware-types.js";
|
|
21
|
+
import { traverseBack } from "./pattern-matching.js";
|
|
22
|
+
import type { RouteMatchResult } from "./pattern-matching.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Immutable snapshot of a resolved route match.
|
|
26
|
+
*
|
|
27
|
+
* Contains everything derivable from (pathname, findMatch, loadManifest)
|
|
28
|
+
* without request context, navigation state, or intercept logic.
|
|
29
|
+
*/
|
|
30
|
+
export interface RouteSnapshot<TEnv = any> {
|
|
31
|
+
/** Raw match result from the trie/pattern matcher */
|
|
32
|
+
matched: RouteMatchResult<TEnv>;
|
|
33
|
+
/** Resolved manifest entry (with loaded handler, loader, etc.) */
|
|
34
|
+
manifestEntry: EntryData;
|
|
35
|
+
/** All entries in the route chain (from traverseBack) */
|
|
36
|
+
entries: EntryData[];
|
|
37
|
+
/** Canonical route key (e.g. "blog.detail") */
|
|
38
|
+
routeKey: string;
|
|
39
|
+
/** Last segment of a dotted route key (e.g. "detail" from "blog.detail") */
|
|
40
|
+
localRouteName: string;
|
|
41
|
+
/** Extracted route params */
|
|
42
|
+
params: Record<string, string>;
|
|
43
|
+
/** Collected route-level middleware from the entry tree */
|
|
44
|
+
routeMiddleware: CollectedMiddleware[];
|
|
45
|
+
/** Merged cache scope from the entry chain */
|
|
46
|
+
cacheScope: CacheScope | null;
|
|
47
|
+
/** Whether the matched route is a passthrough route */
|
|
48
|
+
isPassthrough: boolean;
|
|
49
|
+
/** Response type for non-RSC routes (e.g. "application/json") */
|
|
50
|
+
responseType?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ResolveRouteResult<TEnv = any> =
|
|
54
|
+
| { type: "match"; snapshot: RouteSnapshot<TEnv> }
|
|
55
|
+
| { type: "redirect"; redirectTo: string }
|
|
56
|
+
| null;
|
|
57
|
+
|
|
58
|
+
export interface ResolveRouteDeps<TEnv = any> {
|
|
59
|
+
findMatch: (pathname: string) => RouteMatchResult<TEnv> | null;
|
|
60
|
+
metricsStore?: MetricsStore;
|
|
61
|
+
isSSR?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* When true, skip entries array and cacheScope chain construction.
|
|
64
|
+
* Used by previewMatch which only needs matched, manifestEntry,
|
|
65
|
+
* routeMiddleware, and responseType — avoids an extra traverseBack
|
|
66
|
+
* allocation and cacheScope composition on the hot classification path.
|
|
67
|
+
*/
|
|
68
|
+
lite?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* When true, skip pushing the "route-matching" metric internally.
|
|
71
|
+
* Used by createMatchContextForPartial on the fresh path (no snapshot
|
|
72
|
+
* reuse) so it can measure current + prev + intercept-source findMatch
|
|
73
|
+
* calls under one combined "route-matching" metric. On the reuse path,
|
|
74
|
+
* the partial path emits "route-matching:nav" for the prev +
|
|
75
|
+
* intercept-source lookups only (current-route resolution was done
|
|
76
|
+
* during classification without metrics).
|
|
77
|
+
*/
|
|
78
|
+
skipRouteMatchMetric?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a pathname into a RouteSnapshot.
|
|
83
|
+
*
|
|
84
|
+
* This is the single source of truth for route derivation. It performs:
|
|
85
|
+
* 1. findMatch(pathname)
|
|
86
|
+
* 2. Redirect check
|
|
87
|
+
* 3. loadManifest
|
|
88
|
+
* 4. Passthrough detection
|
|
89
|
+
* 5. collectRouteMiddleware
|
|
90
|
+
* 6. Cache scope chain
|
|
91
|
+
* 7. responseType + localRouteName extraction
|
|
92
|
+
*
|
|
93
|
+
* Metrics timing is preserved identically to the previous inline code.
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveRoute<TEnv = any>(
|
|
96
|
+
pathname: string,
|
|
97
|
+
deps: ResolveRouteDeps<TEnv>,
|
|
98
|
+
): Promise<ResolveRouteResult<TEnv>> {
|
|
99
|
+
const {
|
|
100
|
+
metricsStore,
|
|
101
|
+
isSSR = false,
|
|
102
|
+
lite = false,
|
|
103
|
+
skipRouteMatchMetric = false,
|
|
104
|
+
} = deps;
|
|
105
|
+
|
|
106
|
+
const routeMatchStart =
|
|
107
|
+
metricsStore && !skipRouteMatchMetric ? performance.now() : 0;
|
|
108
|
+
const matched = deps.findMatch(pathname);
|
|
109
|
+
if (metricsStore && !skipRouteMatchMetric) {
|
|
110
|
+
metricsStore.metrics.push({
|
|
111
|
+
label: "route-matching",
|
|
112
|
+
duration: performance.now() - routeMatchStart,
|
|
113
|
+
startTime: routeMatchStart - metricsStore.requestStart,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!matched) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (matched.redirectTo) {
|
|
122
|
+
return { type: "redirect", redirectTo: matched.redirectTo };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const manifestStart = metricsStore ? performance.now() : 0;
|
|
126
|
+
const manifestEntry = await loadManifest(
|
|
127
|
+
matched.entry,
|
|
128
|
+
matched.routeKey,
|
|
129
|
+
pathname,
|
|
130
|
+
metricsStore,
|
|
131
|
+
isSSR,
|
|
132
|
+
);
|
|
133
|
+
if (metricsStore) {
|
|
134
|
+
metricsStore.metrics.push({
|
|
135
|
+
label: "manifest-loading",
|
|
136
|
+
duration: performance.now() - manifestStart,
|
|
137
|
+
startTime: manifestStart - metricsStore.requestStart,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isPassthrough =
|
|
142
|
+
manifestEntry.type === "route" && manifestEntry.isPassthrough === true;
|
|
143
|
+
|
|
144
|
+
let entries: EntryData[];
|
|
145
|
+
let cacheScope: CacheScope | null = null;
|
|
146
|
+
if (lite) {
|
|
147
|
+
entries = [];
|
|
148
|
+
} else {
|
|
149
|
+
({ entries, cacheScope } = buildEntriesAndCacheScope(manifestEntry));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const routeMiddleware = collectRouteMiddleware(
|
|
153
|
+
lite ? traverseBack(manifestEntry) : entries,
|
|
154
|
+
matched.params,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const responseType =
|
|
158
|
+
matched.responseType ||
|
|
159
|
+
(manifestEntry.type === "route" ? manifestEntry.responseType : undefined);
|
|
160
|
+
|
|
161
|
+
const localRouteName = matched.routeKey.includes(".")
|
|
162
|
+
? matched.routeKey.split(".").pop()!
|
|
163
|
+
: matched.routeKey;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
type: "match",
|
|
167
|
+
snapshot: {
|
|
168
|
+
matched,
|
|
169
|
+
manifestEntry,
|
|
170
|
+
entries,
|
|
171
|
+
routeKey: matched.routeKey,
|
|
172
|
+
localRouteName,
|
|
173
|
+
params: matched.params,
|
|
174
|
+
routeMiddleware,
|
|
175
|
+
cacheScope,
|
|
176
|
+
isPassthrough,
|
|
177
|
+
responseType,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fill in the entries and cacheScope fields on a lite snapshot.
|
|
184
|
+
*
|
|
185
|
+
* When classifyRequest produces a lite snapshot (entries=[], cacheScope=null),
|
|
186
|
+
* this function computes the missing fields from manifestEntry without
|
|
187
|
+
* re-running findMatch, loadManifest, or collectRouteMiddleware.
|
|
188
|
+
*
|
|
189
|
+
* If the snapshot already has entries, returns it as-is.
|
|
190
|
+
*/
|
|
191
|
+
export function ensureFullRouteSnapshot<TEnv = any>(
|
|
192
|
+
snapshot: RouteSnapshot<TEnv>,
|
|
193
|
+
): RouteSnapshot<TEnv> {
|
|
194
|
+
if (snapshot.entries.length > 0) {
|
|
195
|
+
return snapshot;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { entries, cacheScope } = buildEntriesAndCacheScope(
|
|
199
|
+
snapshot.manifestEntry,
|
|
200
|
+
);
|
|
201
|
+
return { ...snapshot, entries, cacheScope };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Materialize the entry chain and derive the merged cache scope.
|
|
206
|
+
* Shared by resolveRoute (non-lite) and ensureFullRouteSnapshot.
|
|
207
|
+
*/
|
|
208
|
+
function buildEntriesAndCacheScope(manifestEntry: EntryData): {
|
|
209
|
+
entries: EntryData[];
|
|
210
|
+
cacheScope: CacheScope | null;
|
|
211
|
+
} {
|
|
212
|
+
const entries = [...traverseBack(manifestEntry)];
|
|
213
|
+
let cacheScope: CacheScope | null = null;
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
if (entry.cache) {
|
|
216
|
+
cacheScope = createCacheScope(entry.cache, cacheScope);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { entries, cacheScope };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Test helper: create a RouteSnapshot with sensible defaults and overrides.
|
|
224
|
+
*/
|
|
225
|
+
export function createRouteSnapshot<TEnv = any>(
|
|
226
|
+
overrides?: Partial<RouteSnapshot<TEnv>>,
|
|
227
|
+
): RouteSnapshot<TEnv> {
|
|
228
|
+
return {
|
|
229
|
+
matched: {
|
|
230
|
+
entry: {} as any,
|
|
231
|
+
routeKey: "test",
|
|
232
|
+
params: {},
|
|
233
|
+
optionalParams: new Set(),
|
|
234
|
+
} as RouteMatchResult<TEnv>,
|
|
235
|
+
manifestEntry: { type: "route", shortCode: "R0", parent: null } as any,
|
|
236
|
+
entries: [],
|
|
237
|
+
routeKey: "test",
|
|
238
|
+
localRouteName: "test",
|
|
239
|
+
params: {},
|
|
240
|
+
routeMiddleware: [],
|
|
241
|
+
cacheScope: null,
|
|
242
|
+
isPassthrough: false,
|
|
243
|
+
...overrides,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -449,6 +449,13 @@ export interface RSCRouterInternal<
|
|
|
449
449
|
segmentType?: ErrorInfo["segmentType"],
|
|
450
450
|
): Promise<MatchResult | null>;
|
|
451
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Low-level route matching function.
|
|
454
|
+
* Used by classifyRequest() for request classification without
|
|
455
|
+
* entering the full match pipeline.
|
|
456
|
+
*/
|
|
457
|
+
findMatch(pathname: string, metricsStore?: any): any;
|
|
458
|
+
|
|
452
459
|
/**
|
|
453
460
|
* Debug utility to serialize the manifest for inspection
|
|
454
461
|
* Returns a JSON-friendly representation of all routes and layouts
|
|
@@ -327,6 +327,7 @@ export async function resolveSegment<TEnv>(
|
|
|
327
327
|
deps,
|
|
328
328
|
options,
|
|
329
329
|
routeKey,
|
|
330
|
+
entry,
|
|
330
331
|
);
|
|
331
332
|
segments.push(...orphanSegments);
|
|
332
333
|
}
|
|
@@ -382,6 +383,9 @@ export async function resolveOrphanLayout<TEnv>(
|
|
|
382
383
|
deps: SegmentResolutionDeps<TEnv>,
|
|
383
384
|
options?: ResolveSegmentOptions,
|
|
384
385
|
routeKey?: string,
|
|
386
|
+
/** Parent route entry — its loaders are inherited by the layout so
|
|
387
|
+
* parallel slots inside this layout can access them via useLoader(). */
|
|
388
|
+
parentRouteEntry?: EntryData,
|
|
385
389
|
): Promise<ResolvedSegment[]> {
|
|
386
390
|
invariant(
|
|
387
391
|
orphan.type === "layout" || orphan.type === "cache",
|
|
@@ -397,6 +401,26 @@ export async function resolveOrphanLayout<TEnv>(
|
|
|
397
401
|
deps,
|
|
398
402
|
);
|
|
399
403
|
segments.push(...loaderSegments);
|
|
404
|
+
|
|
405
|
+
// Inherit parent route's loaders so parallel slots inside this layout
|
|
406
|
+
// can access them via useLoader(). Without this, the route's loaders
|
|
407
|
+
// are only in the route's OutletProvider (rendered as <Outlet /> content),
|
|
408
|
+
// which is a child — not a parent — of the layout's context.
|
|
409
|
+
if (
|
|
410
|
+
parentRouteEntry &&
|
|
411
|
+
parentRouteEntry.loader &&
|
|
412
|
+
parentRouteEntry.loader.length > 0 &&
|
|
413
|
+
Object.keys(orphan.parallel).length > 0
|
|
414
|
+
) {
|
|
415
|
+
const inheritedLoaders = await resolveLoaders(
|
|
416
|
+
parentRouteEntry,
|
|
417
|
+
context,
|
|
418
|
+
belongsToRoute,
|
|
419
|
+
deps,
|
|
420
|
+
orphan.shortCode,
|
|
421
|
+
);
|
|
422
|
+
segments.push(...inheritedLoaders);
|
|
423
|
+
}
|
|
400
424
|
}
|
|
401
425
|
|
|
402
426
|
// Handler-first: orphan layout handler executes before its parallels
|
|
@@ -685,6 +709,19 @@ export async function resolveLoadersOnly<TEnv>(
|
|
|
685
709
|
const childBelongsToRoute = belongsToRoute || entry.type === "route";
|
|
686
710
|
for (const layoutEntry of entry.layout) {
|
|
687
711
|
await collectEntryLoaders(layoutEntry, childBelongsToRoute);
|
|
712
|
+
// Inherit route loaders for orphan layouts with parallels
|
|
713
|
+
if (
|
|
714
|
+
entry.type === "route" &&
|
|
715
|
+
entry.loader &&
|
|
716
|
+
entry.loader.length > 0 &&
|
|
717
|
+
Object.keys(layoutEntry.parallel).length > 0
|
|
718
|
+
) {
|
|
719
|
+
await collectEntryLoaders(
|
|
720
|
+
entry,
|
|
721
|
+
childBelongsToRoute,
|
|
722
|
+
layoutEntry.shortCode,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
688
725
|
}
|
|
689
726
|
}
|
|
690
727
|
|
|
@@ -319,6 +319,19 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
|
|
|
319
319
|
const childBelongsToRoute = belongsToRoute || entry.type === "route";
|
|
320
320
|
for (const layoutEntry of entry.layout) {
|
|
321
321
|
await collectEntryLoaders(layoutEntry, childBelongsToRoute);
|
|
322
|
+
// Inherit route loaders for orphan layouts with parallels
|
|
323
|
+
if (
|
|
324
|
+
entry.type === "route" &&
|
|
325
|
+
entry.loader &&
|
|
326
|
+
entry.loader.length > 0 &&
|
|
327
|
+
Object.keys(layoutEntry.parallel).length > 0
|
|
328
|
+
) {
|
|
329
|
+
await collectEntryLoaders(
|
|
330
|
+
entry,
|
|
331
|
+
childBelongsToRoute,
|
|
332
|
+
layoutEntry.shortCode,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
322
335
|
}
|
|
323
336
|
}
|
|
324
337
|
|
|
@@ -840,6 +853,7 @@ export async function resolveSegmentWithRevalidation<TEnv>(
|
|
|
840
853
|
deps,
|
|
841
854
|
actionContext,
|
|
842
855
|
stale,
|
|
856
|
+
entry,
|
|
843
857
|
);
|
|
844
858
|
segments.push(...orphanResult.segments);
|
|
845
859
|
matchedIds.push(...orphanResult.matchedIds);
|
|
@@ -951,6 +965,8 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
951
965
|
deps: SegmentResolutionDeps<TEnv>,
|
|
952
966
|
actionContext?: ActionContext,
|
|
953
967
|
stale?: boolean,
|
|
968
|
+
/** Parent route entry — its loaders are inherited so parallel slots can access them. */
|
|
969
|
+
parentRouteEntry?: EntryData,
|
|
954
970
|
): Promise<SegmentRevalidationResult> {
|
|
955
971
|
invariant(
|
|
956
972
|
orphan.type === "layout" || orphan.type === "cache",
|
|
@@ -978,6 +994,33 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
978
994
|
segments.push(...loaderResult.segments);
|
|
979
995
|
matchedIds.push(...loaderResult.matchedIds);
|
|
980
996
|
|
|
997
|
+
// Inherit parent route's loaders so parallel slots inside this layout
|
|
998
|
+
// can access them via useLoader(). See resolveOrphanLayout in fresh.ts.
|
|
999
|
+
if (
|
|
1000
|
+
parentRouteEntry &&
|
|
1001
|
+
parentRouteEntry.loader &&
|
|
1002
|
+
parentRouteEntry.loader.length > 0 &&
|
|
1003
|
+
Object.keys(orphan.parallel).length > 0
|
|
1004
|
+
) {
|
|
1005
|
+
const inheritedResult = await resolveLoadersWithRevalidation(
|
|
1006
|
+
parentRouteEntry,
|
|
1007
|
+
context,
|
|
1008
|
+
belongsToRoute,
|
|
1009
|
+
clientSegmentIds,
|
|
1010
|
+
prevParams,
|
|
1011
|
+
request,
|
|
1012
|
+
prevUrl,
|
|
1013
|
+
nextUrl,
|
|
1014
|
+
routeKey,
|
|
1015
|
+
deps,
|
|
1016
|
+
actionContext,
|
|
1017
|
+
orphan.shortCode,
|
|
1018
|
+
stale,
|
|
1019
|
+
);
|
|
1020
|
+
segments.push(...inheritedResult.segments);
|
|
1021
|
+
matchedIds.push(...inheritedResult.matchedIds);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
981
1024
|
// Handler-first: resolve orphan layout handler before its parallels
|
|
982
1025
|
// so ctx.set() values are visible to parallel children.
|
|
983
1026
|
matchedIds.push(orphan.shortCode);
|
package/src/router.ts
CHANGED
|
@@ -1033,6 +1033,10 @@ export function createRouter<TEnv = any>(
|
|
|
1033
1033
|
};
|
|
1034
1034
|
})(),
|
|
1035
1035
|
|
|
1036
|
+
// Low-level route matching for request classification
|
|
1037
|
+
findMatch: (pathname: string, metricsStore?: any) =>
|
|
1038
|
+
findMatch(pathname, metricsStore),
|
|
1039
|
+
|
|
1036
1040
|
// Debug utility for manifest inspection
|
|
1037
1041
|
debugManifest: () => buildDebugManifest<TEnv>(routesEntries),
|
|
1038
1042
|
};
|