@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.
@@ -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
  };