@rangojs/router 0.0.0-experimental.c873df95 → 0.0.0-experimental.cb4b3a28

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.
Files changed (131) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +716 -237
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/package.json +3 -3
  6. package/skills/cache-guide/SKILL.md +32 -0
  7. package/skills/caching/SKILL.md +8 -0
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/intercept/SKILL.md +20 -0
  10. package/skills/layout/SKILL.md +22 -0
  11. package/skills/links/SKILL.md +3 -1
  12. package/skills/loader/SKILL.md +53 -43
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +560 -0
  15. package/skills/migrate-react-router/SKILL.md +764 -0
  16. package/skills/parallel/SKILL.md +126 -0
  17. package/skills/prerender/SKILL.md +110 -68
  18. package/skills/rango/SKILL.md +24 -22
  19. package/skills/route/SKILL.md +55 -0
  20. package/skills/router-setup/SKILL.md +87 -2
  21. package/skills/typesafety/SKILL.md +10 -0
  22. package/src/__internal.ts +1 -1
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/navigation-bridge.ts +37 -5
  25. package/src/browser/navigation-client.ts +98 -46
  26. package/src/browser/navigation-store.ts +43 -8
  27. package/src/browser/partial-update.ts +41 -7
  28. package/src/browser/prefetch/cache.ts +16 -6
  29. package/src/browser/prefetch/fetch.ts +68 -6
  30. package/src/browser/prefetch/queue.ts +61 -29
  31. package/src/browser/prefetch/resource-ready.ts +77 -0
  32. package/src/browser/react/Link.tsx +67 -8
  33. package/src/browser/react/NavigationProvider.tsx +13 -4
  34. package/src/browser/react/context.ts +7 -2
  35. package/src/browser/react/use-handle.ts +9 -58
  36. package/src/browser/react/use-router.ts +21 -8
  37. package/src/browser/rsc-router.tsx +26 -3
  38. package/src/browser/scroll-restoration.ts +10 -8
  39. package/src/browser/segment-reconciler.ts +36 -14
  40. package/src/browser/server-action-bridge.ts +8 -6
  41. package/src/browser/types.ts +27 -5
  42. package/src/build/generate-manifest.ts +6 -6
  43. package/src/build/generate-route-types.ts +3 -0
  44. package/src/build/route-trie.ts +50 -24
  45. package/src/build/route-types/include-resolution.ts +8 -1
  46. package/src/build/route-types/router-processing.ts +211 -72
  47. package/src/build/route-types/scan-filter.ts +8 -1
  48. package/src/cache/cache-scope.ts +46 -5
  49. package/src/cache/taint.ts +55 -0
  50. package/src/client.tsx +84 -230
  51. package/src/context-var.ts +72 -2
  52. package/src/handle.ts +40 -0
  53. package/src/index.rsc.ts +3 -1
  54. package/src/index.ts +46 -6
  55. package/src/prerender/store.ts +5 -4
  56. package/src/prerender.ts +138 -77
  57. package/src/reverse.ts +25 -1
  58. package/src/route-definition/dsl-helpers.ts +194 -32
  59. package/src/route-definition/helpers-types.ts +67 -19
  60. package/src/route-definition/index.ts +3 -0
  61. package/src/route-definition/redirect.ts +9 -1
  62. package/src/route-definition/resolve-handler-use.ts +149 -0
  63. package/src/route-types.ts +11 -0
  64. package/src/router/content-negotiation.ts +100 -1
  65. package/src/router/handler-context.ts +82 -23
  66. package/src/router/intercept-resolution.ts +9 -4
  67. package/src/router/loader-resolution.ts +156 -21
  68. package/src/router/match-api.ts +124 -189
  69. package/src/router/match-middleware/background-revalidation.ts +12 -1
  70. package/src/router/match-middleware/cache-lookup.ts +40 -13
  71. package/src/router/match-middleware/cache-store.ts +21 -4
  72. package/src/router/match-middleware/segment-resolution.ts +53 -0
  73. package/src/router/match-result.ts +102 -18
  74. package/src/router/middleware-types.ts +6 -8
  75. package/src/router/middleware.ts +2 -5
  76. package/src/router/navigation-snapshot.ts +182 -0
  77. package/src/router/prerender-match.ts +110 -10
  78. package/src/router/preview-match.ts +30 -102
  79. package/src/router/request-classification.ts +310 -0
  80. package/src/router/route-snapshot.ts +245 -0
  81. package/src/router/router-context.ts +1 -0
  82. package/src/router/router-interfaces.ts +36 -4
  83. package/src/router/router-options.ts +37 -11
  84. package/src/router/segment-resolution/fresh.ts +80 -9
  85. package/src/router/segment-resolution/helpers.ts +29 -24
  86. package/src/router/segment-resolution/revalidation.ts +91 -8
  87. package/src/router/types.ts +1 -0
  88. package/src/router.ts +54 -5
  89. package/src/rsc/handler.ts +472 -372
  90. package/src/rsc/loader-fetch.ts +23 -3
  91. package/src/rsc/manifest-init.ts +5 -1
  92. package/src/rsc/progressive-enhancement.ts +14 -2
  93. package/src/rsc/rsc-rendering.ts +10 -1
  94. package/src/rsc/server-action.ts +8 -0
  95. package/src/rsc/ssr-setup.ts +2 -2
  96. package/src/rsc/types.ts +9 -1
  97. package/src/segment-content-promise.ts +67 -0
  98. package/src/segment-loader-promise.ts +122 -0
  99. package/src/segment-system.tsx +11 -61
  100. package/src/server/context.ts +50 -1
  101. package/src/server/handle-store.ts +19 -0
  102. package/src/server/loader-registry.ts +9 -8
  103. package/src/server/request-context.ts +175 -15
  104. package/src/ssr/index.tsx +3 -0
  105. package/src/static-handler.ts +18 -6
  106. package/src/types/cache-types.ts +4 -4
  107. package/src/types/handler-context.ts +37 -19
  108. package/src/types/loader-types.ts +36 -9
  109. package/src/types/route-entry.ts +1 -1
  110. package/src/types/segments.ts +1 -1
  111. package/src/urls/include-helper.ts +20 -7
  112. package/src/urls/path-helper-types.ts +39 -6
  113. package/src/urls/path-helper.ts +47 -12
  114. package/src/urls/pattern-types.ts +12 -0
  115. package/src/urls/response-types.ts +16 -6
  116. package/src/use-loader.tsx +77 -5
  117. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  118. package/src/vite/discovery/discover-routers.ts +5 -1
  119. package/src/vite/discovery/prerender-collection.ts +128 -74
  120. package/src/vite/discovery/state.ts +13 -4
  121. package/src/vite/index.ts +4 -0
  122. package/src/vite/plugin-types.ts +60 -5
  123. package/src/vite/plugins/expose-id-utils.ts +12 -0
  124. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  125. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  126. package/src/vite/plugins/performance-tracks.ts +88 -0
  127. package/src/vite/plugins/refresh-cmd.ts +88 -26
  128. package/src/vite/rango.ts +19 -2
  129. package/src/vite/router-discovery.ts +178 -37
  130. package/src/vite/utils/prerender-utils.ts +37 -5
  131. package/src/vite/utils/shared-utils.ts +3 -2
@@ -165,10 +165,14 @@ export function withCacheStore<TEnv>(
165
165
  // Combine main segments with intercept segments
166
166
  const allSegmentsToCache = [...allSegments, ...state.interceptSegments];
167
167
 
168
- // Check if any non-loader segments have null components
169
- // This happens when client already had those segments (partial navigation)
168
+ // Check if any non-loader segments have null components from revalidation
169
+ // skip (client already had them). Segments where the handler intentionally
170
+ // returned null are not revalidation skips — re-rendering them will still
171
+ // produce null, so proactive caching would be wasted work.
172
+ const clientIdSet = new Set(ctx.clientSegmentIds);
170
173
  const hasNullComponents = allSegmentsToCache.some(
171
- (s) => s.component === null && s.type !== "loader",
174
+ (s) =>
175
+ s.component === null && s.type !== "loader" && clientIdSet.has(s.id),
172
176
  );
173
177
 
174
178
  const requestCtx = getRequestContext();
@@ -195,6 +199,10 @@ export function withCacheStore<TEnv>(
195
199
  // Proactive caching: render all segments fresh in background
196
200
  // This ensures cache has complete components for future requests
197
201
  requestCtx.waitUntil(async () => {
202
+ // Prevent background metrics from polluting foreground timeline.
203
+ const savedMetrics = ctx.Store.metrics;
204
+ ctx.Store.metrics = undefined;
205
+
198
206
  const start = performance.now();
199
207
  debugLog("cacheStore", "proactive caching started", {
200
208
  pathname: ctx.pathname,
@@ -225,7 +233,9 @@ export function withCacheStore<TEnv>(
225
233
  // Use normal loader access so handle data is captured
226
234
  setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
227
235
 
228
- // Re-resolve ALL segments without revalidation
236
+ // Re-resolve ALL segments without revalidation.
237
+ // Skip DSL loaders — they are never cached (cacheRoute filters them)
238
+ // and are always resolved fresh on each request.
229
239
  const Store = ctx.Store;
230
240
  const freshSegments = await Store.run(() =>
231
241
  resolveAllSegments(
@@ -234,6 +244,7 @@ export function withCacheStore<TEnv>(
234
244
  ctx.matched.params,
235
245
  proactiveHandlerContext,
236
246
  proactiveLoaderPromises,
247
+ { skipLoaders: true },
237
248
  ),
238
249
  );
239
250
 
@@ -285,11 +296,17 @@ export function withCacheStore<TEnv>(
285
296
  });
286
297
  } finally {
287
298
  requestCtx._handleStore = originalHandleStore;
299
+ ctx.Store.metrics = savedMetrics;
288
300
  }
289
301
  });
290
302
  } else {
291
303
  // All segments have components - cache directly
292
304
  // Schedule caching in waitUntil since cacheRoute is now async (key resolution)
305
+ if (INTERNAL_RANGO_DEBUG) {
306
+ console.log(
307
+ `[RSC CacheStore][req:${reqId}] Direct cache path: scheduling cacheRoute for ${ctx.pathname} (${allSegmentsToCache.length} segments, hasNullComponents=${hasNullComponents})`,
308
+ );
309
+ }
293
310
  requestCtx.waitUntil(async () => {
294
311
  const start = performance.now();
295
312
  await cacheScope.cacheRoute(
@@ -87,10 +87,49 @@
87
87
  * if (state.cacheHit) return; // Now we can check
88
88
  */
89
89
  import type { ResolvedSegment } from "../../types.js";
90
+ import type { EntryData } from "../../server/context.js";
91
+ import { _getRequestContext } from "../../server/request-context.js";
90
92
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
91
93
  import { getRouterContext } from "../router-context.js";
92
94
  import type { GeneratorMiddleware } from "./cache-lookup.js";
93
95
 
96
+ /**
97
+ * Check whether any entry in the tree uses loading() (streaming).
98
+ * Matches the router's streaming semantics in fresh.ts: streaming is
99
+ * enabled when `loading` is defined AND not `false`. `loading: false`
100
+ * explicitly disables streaming; `undefined` means no loading at all.
101
+ */
102
+ export function treeHasStreaming(entries: EntryData[]): boolean {
103
+ for (const entry of entries) {
104
+ if (
105
+ "loading" in entry &&
106
+ entry.loading !== undefined &&
107
+ entry.loading !== false
108
+ )
109
+ return true;
110
+ if (entry.layout) {
111
+ if (treeHasStreaming(entry.layout)) return true;
112
+ }
113
+ if (entry.parallel) {
114
+ for (const key in entry.parallel) {
115
+ const parallelEntry = entry.parallel[key as `@${string}`];
116
+ if (parallelEntry) {
117
+ if (
118
+ "loading" in parallelEntry &&
119
+ parallelEntry.loading !== undefined &&
120
+ parallelEntry.loading !== false
121
+ )
122
+ return true;
123
+ if (parallelEntry.layout) {
124
+ if (treeHasStreaming(parallelEntry.layout)) return true;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
94
133
  /**
95
134
  * Creates segment resolution middleware
96
135
  *
@@ -116,6 +155,7 @@ export function withSegmentResolution<TEnv>(
116
155
  const ownStart = performance.now();
117
156
 
118
157
  // If cache hit, segments were already yielded by cache lookup
158
+ // (render barrier is resolved on the cache-hit path)
119
159
  if (state.cacheHit) {
120
160
  if (ms) {
121
161
  ms.metrics.push({
@@ -127,6 +167,11 @@ export function withSegmentResolution<TEnv>(
127
167
  return;
128
168
  }
129
169
 
170
+ const reqCtx = _getRequestContext();
171
+ if (reqCtx && reqCtx._treeHasStreaming === undefined) {
172
+ reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
173
+ }
174
+
130
175
  const { resolveAllSegmentsWithRevalidation, resolveAllSegments } =
131
176
  getRouterContext<TEnv>();
132
177
 
@@ -148,6 +193,10 @@ export function withSegmentResolution<TEnv>(
148
193
  state.segments = segments;
149
194
  state.matchedIds = segments.map((s: { id: string }) => s.id);
150
195
 
196
+ if (reqCtx) {
197
+ reqCtx._resolveRenderBarrier(segments);
198
+ }
199
+
151
200
  // Yield all resolved segments
152
201
  for (const segment of segments) {
153
202
  yield segment;
@@ -178,6 +227,10 @@ export function withSegmentResolution<TEnv>(
178
227
  state.segments = result.segments;
179
228
  state.matchedIds = result.matchedIds;
180
229
 
230
+ if (reqCtx) {
231
+ reqCtx._resolveRenderBarrier(result.segments);
232
+ }
233
+
181
234
  // Yield all resolved segments
182
235
  for (const segment of result.segments) {
183
236
  yield segment;
@@ -62,12 +62,16 @@
62
62
  * SEGMENT FILTERING RULES
63
63
  * =======================
64
64
  *
65
- * For partial match, all segments are included — even those with
66
- * component === null. A null component means the handler returned null
67
- * or revalidation was skipped, but the segment is still structurally
68
- * required: the client needs it in the diff to reconcile its children
69
- * (child layouts, parallels). The client reconciler handles null
70
- * components gracefully (preserves cached component if available).
65
+ * For partial match, segments are filtered:
66
+ *
67
+ * Keep if:
68
+ * - component !== null (needs rendering)
69
+ * - type === "loader" (carries data even with null component)
70
+ * - client doesn't have the segment (structurally required parent node)
71
+ *
72
+ * Skip if:
73
+ * - component === null AND type !== "loader" AND client has it cached
74
+ * - (Revalidation skip — client already has this segment's UI)
71
75
  *
72
76
  *
73
77
  * INTERCEPT HANDLING
@@ -121,6 +125,69 @@ export async function collectSegments(
121
125
  return segments;
122
126
  }
123
127
 
128
+ /**
129
+ * Deduplicate inherited loader segments by loaderId.
130
+ *
131
+ * When a route has loaders and a child layout has parallel slots, the same
132
+ * loader is resolved twice: once for the route and once inherited into the
133
+ * layout (tagged with `_inherited`). The inherited copy is only needed when
134
+ * the route uses `loading()` — in that case, the loader data is inside a
135
+ * LoaderBoundary/Suspense that parallel slots can't reach through. Without
136
+ * loading(), useLoader() traverses parent contexts and finds the data.
137
+ */
138
+ function deduplicateLoaderSegments(
139
+ segments: ResolvedSegment[],
140
+ logPrefix: string,
141
+ ): ResolvedSegment[] {
142
+ // First pass: collect loaderIds of original (non-inherited) segments
143
+ // and whether their parent entry uses loading()
144
+ const originalLoaders = new Set<string>();
145
+ const loadersWithLoading = new Set<string>();
146
+ for (const s of segments) {
147
+ if (s.type === "loader" && s.loaderId && !s._inherited) {
148
+ originalLoaders.add(s.loaderId);
149
+ // If the segment has a sibling with loading, the parent uses loading()
150
+ // We detect this by checking if any non-loader segment in the same
151
+ // namespace has loading defined
152
+ }
153
+ }
154
+ // Check if any layout/route segment has loading — if a loader's namespace
155
+ // matches a segment with loading, the inherited copy is needed
156
+ for (const s of segments) {
157
+ if (s.type !== "loader" && s.loading !== undefined && s.loading !== false) {
158
+ // Find loaders in this namespace
159
+ for (const l of segments) {
160
+ if (l.type === "loader" && l.namespace === s.namespace && l.loaderId) {
161
+ loadersWithLoading.add(l.loaderId);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ const result: ResolvedSegment[] = [];
168
+ let dedupCount = 0;
169
+
170
+ for (const s of segments) {
171
+ if (
172
+ s.type === "loader" &&
173
+ s.loaderId &&
174
+ s._inherited &&
175
+ originalLoaders.has(s.loaderId) &&
176
+ !loadersWithLoading.has(s.loaderId)
177
+ ) {
178
+ dedupCount++;
179
+ continue;
180
+ }
181
+ result.push(s);
182
+ }
183
+
184
+ if (dedupCount > 0) {
185
+ debugLog(logPrefix, `deduped ${dedupCount} inherited loader segment(s)`);
186
+ }
187
+
188
+ return result;
189
+ }
190
+
124
191
  /**
125
192
  * Build the final MatchResult from collected segments and context
126
193
  */
@@ -165,16 +232,23 @@ export function buildMatchResult<TEnv>(
165
232
  // Deduplicate allIds (defense-in-depth for partial match path)
166
233
  allIds = [...new Set(allIds)];
167
234
 
168
- // Include all segments even those with null components.
169
- // A null component means "no visual output" (handler returned null or
170
- // revalidation skipped), but the segment is still structurally required:
171
- // the client needs it in the diff to reconcile children (child layouts,
172
- // parallels). Filtering null-component segments here would cause the
173
- // client to error with "Missing segment" when the ID appears in matched
174
- // but is absent from both the server payload and the client cache.
175
- segmentsToRender = allSegments;
235
+ // Filter out null-component segments only when the client already has
236
+ // them cached (revalidation skip). If the client doesn't have the segment,
237
+ // it must be included even with null component — it's structurally required
238
+ // as a parent node for child layouts/parallels to reconcile against.
239
+ // Loader segments are always included as they carry data.
240
+ const clientIdSet = new Set(ctx.clientSegmentIds);
241
+ segmentsToRender = allSegments.filter(
242
+ (s) =>
243
+ s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
244
+ );
176
245
  }
177
246
 
247
+ const dedupedSegments = deduplicateLoaderSegments(
248
+ segmentsToRender,
249
+ logPrefix,
250
+ );
251
+
178
252
  debugLog(logPrefix, "all segments", {
179
253
  segments: allSegments.map((s) => ({
180
254
  id: s.id,
@@ -183,13 +257,23 @@ export function buildMatchResult<TEnv>(
183
257
  })),
184
258
  });
185
259
  debugLog(logPrefix, "segments to render", {
186
- segmentIds: segmentsToRender.map((s) => s.id),
260
+ segmentIds: dedupedSegments.map((s) => s.id),
187
261
  });
188
262
 
263
+ // Remove deduped loader IDs from matched so the client doesn't treat
264
+ // them as missing segments and trigger a fallback refetch.
265
+ const removedIds = new Set(
266
+ segmentsToRender
267
+ .filter((s) => !dedupedSegments.includes(s))
268
+ .map((s) => s.id),
269
+ );
270
+ const matchedIds =
271
+ removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
272
+
189
273
  return {
190
- segments: segmentsToRender,
191
- matched: allIds,
192
- diff: segmentsToRender.map((s) => s.id),
274
+ segments: dedupedSegments,
275
+ matched: matchedIds,
276
+ diff: dedupedSegments.map((s) => s.id),
193
277
  params: ctx.matched.params,
194
278
  routeName: ctx.routeKey,
195
279
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
@@ -27,8 +27,12 @@ type GetVariableFn = {
27
27
  * Set variable function type
28
28
  */
29
29
  type SetVariableFn = {
30
- <T>(contextVar: ContextVar<T>, value: T): void;
31
- <K extends keyof DefaultVars>(key: K, value: DefaultVars[K]): void;
30
+ <T>(contextVar: ContextVar<T>, value: T, options?: { cache?: boolean }): void;
31
+ <K extends keyof DefaultVars>(
32
+ key: K,
33
+ value: DefaultVars[K],
34
+ options?: { cache?: boolean },
35
+ ): void;
32
36
  };
33
37
 
34
38
  /**
@@ -91,12 +95,6 @@ export interface MiddlewareContext<
91
95
  /** Set a context variable (shared with route handlers) */
92
96
  set: SetVariableFn;
93
97
 
94
- /**
95
- * Middleware-injected variables.
96
- * Same shared dictionary as `ctx.get()`/`ctx.set()`.
97
- */
98
- var: DefaultVars;
99
-
100
98
  /**
101
99
  * Set a response header - can be called before or after `next()`.
102
100
  *
@@ -204,12 +204,9 @@ export function createMiddlewareContext<TEnv>(
204
204
  get: ((keyOrVar: any) =>
205
205
  contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
206
206
 
207
- set: ((keyOrVar: any, value: unknown) => {
208
- contextSet(variables, keyOrVar, value);
207
+ set: ((keyOrVar: any, value: unknown, options?: any) => {
208
+ contextSet(variables, keyOrVar, value, options);
209
209
  }) as MiddlewareContext<TEnv>["set"],
210
-
211
- var: variables as MiddlewareContext<TEnv>["var"],
212
-
213
210
  header(name: string, value: string): void {
214
211
  // Before next(): delegate to shared RequestContext stub
215
212
  if (isPreNext()) {
@@ -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
+ }