@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.
@@ -36,7 +36,14 @@ import {
36
36
  setRequestContextPrevRouteKey,
37
37
  } from "../server/request-context.js";
38
38
  import { isAutoGeneratedRouteName } from "../route-name.js";
39
+ import type { DefaultRouteName } from "../types/global-namespace.js";
39
40
  import { debugLog, debugWarn } from "./logging.js";
41
+ import {
42
+ resolveRoute,
43
+ ensureFullRouteSnapshot,
44
+ type RouteSnapshot,
45
+ } from "./route-snapshot.js";
46
+ import { resolveNavigation } from "./navigation-snapshot.js";
40
47
 
41
48
  /**
42
49
  * Create match context for full requests (document/SSR).
@@ -52,54 +59,36 @@ export async function createMatchContextForFull<TEnv>(
52
59
 
53
60
  const metricsStore = deps.getMetricsStore();
54
61
 
55
- const routeMatchStart = metricsStore ? performance.now() : 0;
56
- const matched = deps.findMatch(pathname, metricsStore);
57
- if (metricsStore) {
58
- metricsStore.metrics.push({
59
- label: "route-matching",
60
- duration: performance.now() - routeMatchStart,
61
- startTime: routeMatchStart - metricsStore.requestStart,
62
- });
63
- }
62
+ // Full renders always resolve fresh with isSSR: true because loadManifest
63
+ // keys its cache on isSSR and stamps Store.isSSR for downstream behavior.
64
+ const result = await resolveRoute<TEnv>(pathname, {
65
+ findMatch: (p) => deps.findMatch(p, metricsStore),
66
+ metricsStore,
67
+ isSSR: true,
68
+ });
64
69
 
65
- if (!matched) {
70
+ if (!result) {
66
71
  throw new RouteNotFoundError(`No route matched for ${pathname}`, {
67
72
  cause: { pathname, method: request.method },
68
73
  });
69
74
  }
70
75
 
71
- if (matched.redirectTo) {
76
+ if (result.type === "redirect") {
72
77
  return {
73
78
  type: "redirect",
74
- redirectUrl: matched.redirectTo + url.search,
79
+ redirectUrl: result.redirectTo + url.search,
75
80
  };
76
81
  }
77
82
 
78
- const manifestStart = metricsStore ? performance.now() : 0;
79
- const manifestEntry = await loadManifest(
80
- matched.entry,
81
- matched.routeKey,
82
- pathname,
83
- metricsStore,
84
- true,
85
- );
86
- if (metricsStore) {
87
- metricsStore.metrics.push({
88
- label: "manifest-loading",
89
- duration: performance.now() - manifestStart,
90
- startTime: manifestStart - metricsStore.requestStart,
91
- });
92
- }
83
+ const snapshot = result.snapshot;
93
84
 
94
- if (manifestEntry.type === "route" && manifestEntry.isPassthrough === true) {
85
+ const { matched } = snapshot;
86
+
87
+ // Backward compat: downstream middleware reads matched.pt
88
+ if (snapshot.isPassthrough) {
95
89
  matched.pt = true;
96
90
  }
97
91
 
98
- const routeMiddleware = collectRouteMiddleware(
99
- traverseBack(manifestEntry),
100
- matched.params,
101
- );
102
-
103
92
  // Clean URL without internal _rsc* params for userland access
104
93
  const cleanUrl = stripInternalParams(url);
105
94
 
@@ -131,14 +120,6 @@ export async function createMatchContextForFull<TEnv>(
131
120
  Store.metrics = metricsStore;
132
121
  }
133
122
 
134
- const entries = [...traverseBack(manifestEntry)];
135
- let cacheScope: CacheScope | null = null;
136
- for (const entry of entries) {
137
- if (entry.cache) {
138
- cacheScope = createCacheScope(entry.cache, cacheScope);
139
- }
140
- }
141
-
142
123
  return {
143
124
  request,
144
125
  url: cleanUrl,
@@ -151,12 +132,10 @@ export async function createMatchContextForFull<TEnv>(
151
132
  prevParams: {},
152
133
  prevMatch: null,
153
134
  matched,
154
- manifestEntry,
155
- entries,
135
+ manifestEntry: snapshot.manifestEntry,
136
+ entries: snapshot.entries,
156
137
  routeKey: matched.routeKey,
157
- localRouteName: matched.routeKey.includes(".")
158
- ? matched.routeKey.split(".").pop()!
159
- : matched.routeKey,
138
+ localRouteName: snapshot.localRouteName,
160
139
  handlerContext,
161
140
  loaderPromises,
162
141
  routeMap: deps.getRouteMap(),
@@ -172,16 +151,16 @@ export async function createMatchContextForFull<TEnv>(
172
151
  segments: { path: [], ids: [] },
173
152
  toRouteName:
174
153
  matched.routeKey && !isAutoGeneratedRouteName(matched.routeKey)
175
- ? matched.routeKey
154
+ ? (matched.routeKey as DefaultRouteName)
176
155
  : undefined,
177
156
  },
178
157
  isSameRouteNavigation: false,
179
158
  interceptResult: null,
180
- cacheScope,
159
+ cacheScope: snapshot.cacheScope,
181
160
  isIntercept: false,
182
161
  actionContext: undefined,
183
162
  isAction: false,
184
- routeMiddleware,
163
+ routeMiddleware: snapshot.routeMiddleware,
185
164
  isFullMatch: true,
186
165
  };
187
166
  }
@@ -201,100 +180,85 @@ export async function createMatchContextForPartial<TEnv>(
201
180
 
202
181
  const metricsStore = deps.getMetricsStore();
203
182
 
204
- const clientSegmentIds =
205
- url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
206
- const stale = url.searchParams.get("_rsc_stale") === "true";
207
- const previousUrl =
208
- request.headers.get("X-RSC-Router-Client-Path") ||
209
- request.headers.get("Referer");
210
- const interceptSourceUrl = request.headers.get(
211
- "X-RSC-Router-Intercept-Source",
212
- );
183
+ const isHmr = !!request.headers.get("X-RSC-HMR");
213
184
 
214
185
  // HMR: clear manifest cache so stale handler references are discarded
215
- if (request.headers.get("X-RSC-HMR")) {
186
+ if (isHmr) {
216
187
  clearManifestCache();
217
188
  }
218
189
 
219
- if (!previousUrl) {
220
- return null;
221
- }
222
-
223
- let prevUrl: URL;
224
- try {
225
- prevUrl = new URL(previousUrl, url.origin);
226
- } catch {
227
- return null;
228
- }
190
+ // Reuse the classified snapshot when available and not invalidated by HMR.
191
+ // classifyRequest already called resolveRoute(lite) with isSSR=false, which
192
+ // matches the partial path. On HMR, discard to pick up manifest changes.
193
+ const classifiedRoute = isHmr
194
+ ? undefined
195
+ : getRequestContext()?._classifiedRoute;
196
+
197
+ // Time route matching. On the reuse path, only nav findMatch calls are new
198
+ // (current-route findMatch and manifest-loading were already timed during
199
+ // classifyRequest via its metricsStore). On the fresh path, all findMatch
200
+ // calls are measured together.
201
+ const routeMatchStart = metricsStore ? performance.now() : 0;
229
202
 
230
- let interceptContextUrl: URL;
231
- try {
232
- interceptContextUrl = interceptSourceUrl
233
- ? new URL(interceptSourceUrl, url.origin)
234
- : prevUrl;
235
- } catch {
236
- interceptContextUrl = prevUrl;
237
- }
203
+ let snapshot: RouteSnapshot<TEnv>;
204
+ if (classifiedRoute && classifiedRoute.manifestEntry) {
205
+ snapshot = ensureFullRouteSnapshot(classifiedRoute);
206
+ } else {
207
+ const result = await resolveRoute<TEnv>(pathname, {
208
+ findMatch: (p) => deps.findMatch(p, metricsStore),
209
+ metricsStore,
210
+ isSSR: false,
211
+ skipRouteMatchMetric: true,
212
+ });
238
213
 
239
- const routeMatchStart = metricsStore ? performance.now() : 0;
240
- const prevMatch = deps.findMatch(prevUrl.pathname);
241
- const prevParams = prevMatch?.params || {};
242
- const interceptContextMatch = interceptSourceUrl
243
- ? deps.findMatch(interceptContextUrl.pathname)
244
- : prevMatch;
214
+ if (!result) {
215
+ throw new RouteNotFoundError(`No route matched for ${pathname}`, {
216
+ cause: { pathname, method: request.method },
217
+ });
218
+ }
245
219
 
246
- const matched = deps.findMatch(pathname, metricsStore);
220
+ if (result.type === "redirect") {
221
+ return null;
222
+ }
247
223
 
248
- if (metricsStore) {
249
- metricsStore.metrics.push({
250
- label: "route-matching",
251
- duration: performance.now() - routeMatchStart,
252
- startTime: routeMatchStart - metricsStore.requestStart,
253
- });
224
+ snapshot = result.snapshot;
254
225
  }
255
226
 
256
- if (!matched) {
257
- throw new RouteNotFoundError(`No route matched for ${pathname}`, {
258
- cause: { pathname, method: request.method, previousUrl },
259
- });
260
- }
227
+ const { matched } = snapshot;
261
228
 
262
- if (matched.redirectTo) {
263
- return null;
229
+ // Backward compat: downstream middleware reads matched.pt
230
+ if (snapshot.isPassthrough) {
231
+ matched.pt = true;
264
232
  }
265
233
 
266
- if (prevMatch && prevMatch.entry !== matched.entry && !matched.pr) {
267
- debugLog("matchPartial", "route group changed", {
268
- from: prevMatch.routeKey,
269
- to: matched.routeKey,
270
- });
234
+ // Navigation state (prev + intercept-source findMatch calls)
235
+ const nav = resolveNavigation(request, url, matched.routeKey, {
236
+ findMatch: deps.findMatch,
237
+ });
238
+ if (!nav) {
239
+ return null;
271
240
  }
272
241
 
273
- const manifestStart = metricsStore ? performance.now() : 0;
274
- const manifestEntry = await loadManifest(
275
- matched.entry,
276
- matched.routeKey,
277
- pathname,
278
- metricsStore,
279
- false,
280
- );
242
+ // Push route-matching metric. On the fresh path this covers all findMatch
243
+ // calls (current + prev + intercept-source). On the reuse path, current-route
244
+ // findMatch was already timed during classification, so this only covers
245
+ // the nav lookups (prev + intercept-source).
281
246
  if (metricsStore) {
247
+ const isReuse = !!classifiedRoute;
282
248
  metricsStore.metrics.push({
283
- label: "manifest-loading",
284
- duration: performance.now() - manifestStart,
285
- startTime: manifestStart - metricsStore.requestStart,
249
+ label: isReuse ? "route-matching:nav" : "route-matching",
250
+ duration: performance.now() - routeMatchStart,
251
+ startTime: routeMatchStart - metricsStore.requestStart,
286
252
  });
287
253
  }
288
254
 
289
- if (manifestEntry.type === "route" && manifestEntry.isPassthrough === true) {
290
- matched.pt = true;
255
+ if (nav.prevMatch && nav.prevMatch.entry !== matched.entry && !matched.pr) {
256
+ debugLog("matchPartial", "route group changed", {
257
+ from: nav.prevMatch.routeKey,
258
+ to: matched.routeKey,
259
+ });
291
260
  }
292
261
 
293
- const routeMiddleware = collectRouteMiddleware(
294
- traverseBack(manifestEntry),
295
- matched.params,
296
- );
297
-
298
262
  // Clean URL without internal _rsc* params for userland access
299
263
  const cleanUrl = stripInternalParams(url);
300
264
 
@@ -311,9 +275,8 @@ export async function createMatchContextForPartial<TEnv>(
311
275
  matched.pt === true,
312
276
  );
313
277
 
314
- const clientSegmentSet = new Set(clientSegmentIds);
315
278
  debugLog("matchPartial", "client segments", {
316
- segments: Array.from(clientSegmentSet),
279
+ segments: Array.from(nav.clientSegmentSet),
317
280
  });
318
281
 
319
282
  const loaderPromises = new Map<string, Promise<any>>();
@@ -331,100 +294,78 @@ export async function createMatchContextForPartial<TEnv>(
331
294
  Store.metrics = metricsStore;
332
295
  }
333
296
 
334
- const isSameRouteNavigation = !!(
335
- interceptContextMatch && interceptContextMatch.routeKey === matched.routeKey
336
- );
337
-
338
- if (interceptSourceUrl) {
297
+ if (nav.hasInterceptSource) {
339
298
  debugLog("matchPartial.intercept", "intercept context detected", {
340
299
  currentUrl: pathname,
341
- interceptSource: interceptSourceUrl,
342
- contextRoute: interceptContextMatch?.routeKey,
300
+ interceptSource: nav.interceptContextUrl.href,
301
+ contextRoute: nav.interceptContextMatch?.routeKey,
343
302
  currentRoute: matched.routeKey,
344
- sameRouteNavigation: isSameRouteNavigation,
303
+ sameRouteNavigation: nav.isSameRouteNavigation,
345
304
  });
346
305
  }
347
306
 
348
- const localRouteName = matched.routeKey.includes(".")
349
- ? matched.routeKey.split(".").pop()!
350
- : matched.routeKey;
351
-
352
- const filteredSegmentIds = clientSegmentIds.filter((id) => {
353
- if (id.includes(".@")) return false;
354
- if (/D\d+\./.test(id)) return false;
355
- return true;
356
- });
357
- const effectiveFromUrl = interceptSourceUrl ? interceptContextUrl : prevUrl;
358
- const effectiveFromMatch = interceptSourceUrl
359
- ? interceptContextMatch
360
- : prevMatch;
361
-
362
307
  // Store previous route key on the request context for revalidation
363
308
  // fromRouteName. Uses effectiveFromMatch so intercept-source navigations
364
309
  // see the intercept origin route, not the plain previous URL route.
365
- setRequestContextPrevRouteKey(effectiveFromMatch?.routeKey);
310
+ setRequestContextPrevRouteKey(nav.effectiveFromMatch?.routeKey);
366
311
 
367
312
  const interceptSelectorContext: InterceptSelectorContext = {
368
- from: effectiveFromUrl,
313
+ from: nav.effectiveFromUrl,
369
314
  to: cleanUrl,
370
315
  params: matched.params,
371
316
  request,
372
317
  env,
373
318
  segments: {
374
- path: effectiveFromUrl.pathname.split("/").filter(Boolean),
375
- ids: filteredSegmentIds,
319
+ path: nav.effectiveFromUrl.pathname.split("/").filter(Boolean),
320
+ ids: nav.filteredSegmentIds,
376
321
  },
377
322
  fromRouteName:
378
- effectiveFromMatch?.routeKey &&
379
- !isAutoGeneratedRouteName(effectiveFromMatch.routeKey)
380
- ? effectiveFromMatch.routeKey
323
+ nav.effectiveFromMatch?.routeKey &&
324
+ !isAutoGeneratedRouteName(nav.effectiveFromMatch.routeKey)
325
+ ? (nav.effectiveFromMatch.routeKey as DefaultRouteName)
381
326
  : undefined,
382
327
  toRouteName:
383
328
  matched.routeKey && !isAutoGeneratedRouteName(matched.routeKey)
384
- ? matched.routeKey
329
+ ? (matched.routeKey as DefaultRouteName)
385
330
  : undefined,
386
331
  };
387
332
  const isAction = !!actionContext;
388
333
 
389
- const clientHasInterceptSegments = [...clientSegmentSet].some((id) =>
334
+ const clientHasInterceptSegments = [...nav.clientSegmentSet].some((id) =>
390
335
  id.includes(".@"),
391
336
  );
392
337
  const skipInterceptForAction = isAction && !clientHasInterceptSegments;
393
338
  const interceptResult =
394
- isSameRouteNavigation || skipInterceptForAction
339
+ nav.isSameRouteNavigation || skipInterceptForAction
395
340
  ? null
396
341
  : findInterceptForRoute(
397
342
  matched.routeKey,
398
- manifestEntry.parent,
343
+ snapshot.manifestEntry.parent,
399
344
  interceptSelectorContext,
400
345
  isAction,
401
346
  ) ||
402
- (localRouteName !== matched.routeKey
347
+ (snapshot.localRouteName !== matched.routeKey
403
348
  ? findInterceptForRoute(
404
- localRouteName,
405
- manifestEntry.parent,
349
+ snapshot.localRouteName,
350
+ snapshot.manifestEntry.parent,
406
351
  interceptSelectorContext,
407
352
  isAction,
408
353
  )
409
354
  : null);
410
355
 
356
+ // Make a mutable copy of clientSegmentSet so we can delete entries
357
+ // for same-route navigation forcing
358
+ const clientSegmentSet = new Set(nav.clientSegmentSet);
359
+
411
360
  if (
412
- isSameRouteNavigation &&
413
- manifestEntry.type === "route" &&
414
- interceptSourceUrl
361
+ nav.isSameRouteNavigation &&
362
+ snapshot.manifestEntry.type === "route" &&
363
+ nav.hasInterceptSource
415
364
  ) {
416
365
  debugLog("matchPartial.intercept", "forcing route segment render", {
417
- segmentId: manifestEntry.shortCode,
366
+ segmentId: snapshot.manifestEntry.shortCode,
418
367
  });
419
- clientSegmentSet.delete(manifestEntry.shortCode);
420
- }
421
-
422
- const entries = [...traverseBack(manifestEntry)];
423
- let cacheScope: CacheScope | null = null;
424
- for (const entry of entries) {
425
- if (entry.cache) {
426
- cacheScope = createCacheScope(entry.cache, cacheScope);
427
- }
368
+ clientSegmentSet.delete(snapshot.manifestEntry.shortCode);
428
369
  }
429
370
 
430
371
  const isIntercept = !!interceptResult;
@@ -434,31 +375,31 @@ export async function createMatchContextForPartial<TEnv>(
434
375
  url: cleanUrl,
435
376
  pathname,
436
377
  env,
437
- clientSegmentIds,
378
+ clientSegmentIds: nav.clientSegmentIds,
438
379
  clientSegmentSet,
439
- stale,
440
- prevUrl,
441
- prevParams,
442
- prevMatch,
380
+ stale: nav.stale,
381
+ prevUrl: nav.prevUrl,
382
+ prevParams: nav.prevParams,
383
+ prevMatch: nav.prevMatch,
443
384
  matched,
444
- manifestEntry,
445
- entries,
385
+ manifestEntry: snapshot.manifestEntry,
386
+ entries: snapshot.entries,
446
387
  routeKey: matched.routeKey,
447
- localRouteName,
388
+ localRouteName: snapshot.localRouteName,
448
389
  handlerContext,
449
390
  loaderPromises,
450
391
  routeMap: deps.getRouteMap(),
451
392
  metricsStore,
452
393
  Store,
453
- interceptContextMatch,
394
+ interceptContextMatch: nav.interceptContextMatch,
454
395
  interceptSelectorContext,
455
- isSameRouteNavigation,
396
+ isSameRouteNavigation: nav.isSameRouteNavigation,
456
397
  interceptResult,
457
- cacheScope,
398
+ cacheScope: snapshot.cacheScope,
458
399
  isIntercept,
459
400
  actionContext,
460
401
  isAction,
461
- routeMiddleware,
402
+ routeMiddleware: snapshot.routeMiddleware,
462
403
  isFullMatch: false,
463
404
  };
464
405
  }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Navigation Snapshot
3
+ *
4
+ * Pure data type representing the navigation-specific state for partial requests.
5
+ * Consolidates the header parsing, previous-route matching, intercept-context
6
+ * detection, and segment ID filtering that previously lived inline in
7
+ * createMatchContextForPartial (match-api.ts).
8
+ *
9
+ * resolveNavigation() is the factory: given a request + URL + current route key,
10
+ * it returns a NavigationSnapshot (or null if no previous URL).
11
+ */
12
+
13
+ import type { RouteMatchResult } from "./pattern-matching.js";
14
+
15
+ /**
16
+ * Snapshot of navigation state for a partial (navigation/action) request.
17
+ *
18
+ * Contains the "where are we coming from?" data: previous route, intercept
19
+ * source, client segment state, and derived flags.
20
+ */
21
+ export interface NavigationSnapshot {
22
+ /** Previous page URL (from X-RSC-Router-Client-Path or Referer) */
23
+ prevUrl: URL;
24
+ /** Params from the previous route match */
25
+ prevParams: Record<string, string>;
26
+ /** Previous route match result (null if prev URL doesn't match any route) */
27
+ prevMatch: RouteMatchResult | null;
28
+
29
+ /** URL used as intercept context source */
30
+ interceptContextUrl: URL;
31
+ /** Route match for the intercept context URL */
32
+ interceptContextMatch: RouteMatchResult | null;
33
+
34
+ /** Raw segment IDs the client currently has */
35
+ clientSegmentIds: string[];
36
+ /** Set version for O(1) lookup */
37
+ clientSegmentSet: Set<string>;
38
+ /** Segment IDs filtered to remove parallel (.@) and loader (D\d+.) entries */
39
+ filteredSegmentIds: string[];
40
+
41
+ /** Whether client considers its cache stale */
42
+ stale: boolean;
43
+
44
+ /** Whether the intercept context route is the same as the current route */
45
+ isSameRouteNavigation: boolean;
46
+
47
+ /** Effective "from" URL (intercept source URL when present, else prevUrl) */
48
+ effectiveFromUrl: URL;
49
+ /** Effective "from" match (intercept source match when present, else prevMatch) */
50
+ effectiveFromMatch: RouteMatchResult | null;
51
+
52
+ /** Whether an intercept source header was present */
53
+ hasInterceptSource: boolean;
54
+
55
+ /** Whether an HMR request header was present */
56
+ isHmr: boolean;
57
+ }
58
+
59
+ export interface ResolveNavigationDeps {
60
+ findMatch: (pathname: string) => RouteMatchResult | null;
61
+ }
62
+
63
+ /**
64
+ * Resolve navigation state from a partial request.
65
+ *
66
+ * Returns null if no previous URL is available (required for partial navigation).
67
+ *
68
+ * @param request - The incoming HTTP request
69
+ * @param url - Parsed URL of the request
70
+ * @param currentRouteKey - Route key of the current (target) route match
71
+ * @param deps - Dependencies (findMatch)
72
+ */
73
+ export function resolveNavigation(
74
+ request: Request,
75
+ url: URL,
76
+ currentRouteKey: string,
77
+ deps: ResolveNavigationDeps,
78
+ ): NavigationSnapshot | null {
79
+ // Parse client state from RSC request params/headers
80
+ const clientSegmentIds =
81
+ url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
82
+ const stale = url.searchParams.get("_rsc_stale") === "true";
83
+ const previousUrl =
84
+ request.headers.get("X-RSC-Router-Client-Path") ||
85
+ request.headers.get("Referer");
86
+ const interceptSourceUrl = request.headers.get(
87
+ "X-RSC-Router-Intercept-Source",
88
+ );
89
+ const isHmr = !!request.headers.get("X-RSC-HMR");
90
+
91
+ if (!previousUrl) {
92
+ return null;
93
+ }
94
+
95
+ // Parse previous URL
96
+ let prevUrl: URL;
97
+ try {
98
+ prevUrl = new URL(previousUrl, url.origin);
99
+ } catch {
100
+ return null;
101
+ }
102
+
103
+ // Parse intercept context URL
104
+ let interceptContextUrl: URL;
105
+ try {
106
+ interceptContextUrl = interceptSourceUrl
107
+ ? new URL(interceptSourceUrl, url.origin)
108
+ : prevUrl;
109
+ } catch {
110
+ interceptContextUrl = prevUrl;
111
+ }
112
+
113
+ // Match previous and intercept context routes
114
+ const prevMatch = deps.findMatch(prevUrl.pathname);
115
+ const prevParams = prevMatch?.params || {};
116
+ const interceptContextMatch = interceptSourceUrl
117
+ ? deps.findMatch(interceptContextUrl.pathname)
118
+ : prevMatch;
119
+
120
+ // Derived state
121
+ const isSameRouteNavigation = !!(
122
+ interceptContextMatch && interceptContextMatch.routeKey === currentRouteKey
123
+ );
124
+
125
+ const hasInterceptSource = !!interceptSourceUrl;
126
+ const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : prevUrl;
127
+ const effectiveFromMatch = hasInterceptSource
128
+ ? interceptContextMatch
129
+ : prevMatch;
130
+
131
+ // Filter segment IDs: remove parallel (.@) and loader (D\d+.) entries
132
+ const filteredSegmentIds = clientSegmentIds.filter((id) => {
133
+ if (id.includes(".@")) return false;
134
+ if (/D\d+\./.test(id)) return false;
135
+ return true;
136
+ });
137
+
138
+ const clientSegmentSet = new Set(clientSegmentIds);
139
+
140
+ return {
141
+ prevUrl,
142
+ prevParams,
143
+ prevMatch,
144
+ interceptContextUrl,
145
+ interceptContextMatch,
146
+ clientSegmentIds,
147
+ clientSegmentSet,
148
+ filteredSegmentIds,
149
+ stale,
150
+ isSameRouteNavigation,
151
+ effectiveFromUrl,
152
+ effectiveFromMatch,
153
+ hasInterceptSource,
154
+ isHmr,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Test helper: create a NavigationSnapshot with sensible defaults and overrides.
160
+ */
161
+ export function createNavigationSnapshot(
162
+ overrides?: Partial<NavigationSnapshot>,
163
+ ): NavigationSnapshot {
164
+ const defaultUrl = new URL("http://localhost/");
165
+ return {
166
+ prevUrl: defaultUrl,
167
+ prevParams: {},
168
+ prevMatch: null,
169
+ interceptContextUrl: defaultUrl,
170
+ interceptContextMatch: null,
171
+ clientSegmentIds: [],
172
+ clientSegmentSet: new Set(),
173
+ filteredSegmentIds: [],
174
+ stale: false,
175
+ isSameRouteNavigation: false,
176
+ effectiveFromUrl: defaultUrl,
177
+ effectiveFromMatch: null,
178
+ hasInterceptSource: false,
179
+ isHmr: false,
180
+ ...overrides,
181
+ };
182
+ }