@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
package/src/router/match-api.ts
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 (!
|
|
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 (
|
|
76
|
+
if (result.type === "redirect") {
|
|
72
77
|
return {
|
|
73
78
|
type: "redirect",
|
|
74
|
-
redirectUrl:
|
|
79
|
+
redirectUrl: result.redirectTo + url.search,
|
|
75
80
|
};
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 (
|
|
186
|
+
if (isHmr) {
|
|
216
187
|
clearManifestCache();
|
|
217
188
|
}
|
|
218
189
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
220
|
+
if (result.type === "redirect") {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
247
223
|
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
229
|
+
// Backward compat: downstream middleware reads matched.pt
|
|
230
|
+
if (snapshot.isPassthrough) {
|
|
231
|
+
matched.pt = true;
|
|
264
232
|
}
|
|
265
233
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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: "
|
|
284
|
-
duration: performance.now() -
|
|
285
|
-
startTime:
|
|
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 (
|
|
290
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|