@rangojs/router 0.0.0-experimental.8678bb02 → 0.0.0-experimental.87

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 (147) hide show
  1. package/README.md +126 -38
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +847 -384
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +5 -5
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/hooks/SKILL.md +28 -20
  10. package/skills/intercept/SKILL.md +20 -0
  11. package/skills/layout/SKILL.md +22 -0
  12. package/skills/links/SKILL.md +91 -17
  13. package/skills/loader/SKILL.md +35 -2
  14. package/skills/middleware/SKILL.md +34 -3
  15. package/skills/migrate-nextjs/SKILL.md +560 -0
  16. package/skills/migrate-react-router/SKILL.md +765 -0
  17. package/skills/parallel/SKILL.md +59 -0
  18. package/skills/prerender/SKILL.md +110 -68
  19. package/skills/rango/SKILL.md +24 -22
  20. package/skills/response-routes/SKILL.md +8 -0
  21. package/skills/route/SKILL.md +24 -0
  22. package/skills/router-setup/SKILL.md +35 -0
  23. package/skills/streams-and-websockets/SKILL.md +283 -0
  24. package/skills/typesafety/SKILL.md +3 -1
  25. package/src/__internal.ts +1 -1
  26. package/src/browser/app-shell.ts +52 -0
  27. package/src/browser/app-version.ts +14 -0
  28. package/src/browser/navigation-bridge.ts +87 -6
  29. package/src/browser/navigation-client.ts +128 -77
  30. package/src/browser/navigation-store.ts +68 -9
  31. package/src/browser/partial-update.ts +60 -7
  32. package/src/browser/prefetch/cache.ts +129 -21
  33. package/src/browser/prefetch/fetch.ts +156 -18
  34. package/src/browser/prefetch/queue.ts +36 -5
  35. package/src/browser/rango-state.ts +53 -13
  36. package/src/browser/react/Link.tsx +72 -8
  37. package/src/browser/react/NavigationProvider.tsx +57 -11
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-navigation.ts +22 -2
  41. package/src/browser/react/use-params.ts +11 -1
  42. package/src/browser/react/use-router.ts +29 -9
  43. package/src/browser/rsc-router.tsx +60 -9
  44. package/src/browser/scroll-restoration.ts +10 -8
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/server-action-bridge.ts +8 -18
  47. package/src/browser/types.ts +33 -5
  48. package/src/build/generate-manifest.ts +6 -6
  49. package/src/build/generate-route-types.ts +3 -0
  50. package/src/build/route-trie.ts +50 -24
  51. package/src/build/route-types/include-resolution.ts +8 -1
  52. package/src/build/route-types/router-processing.ts +211 -72
  53. package/src/build/route-types/scan-filter.ts +8 -1
  54. package/src/cache/cf/cf-cache-store.ts +5 -7
  55. package/src/client.tsx +84 -230
  56. package/src/deps/browser.ts +0 -1
  57. package/src/handle.ts +40 -0
  58. package/src/index.rsc.ts +6 -1
  59. package/src/index.ts +49 -6
  60. package/src/outlet-context.ts +1 -1
  61. package/src/prerender/store.ts +5 -4
  62. package/src/prerender.ts +138 -77
  63. package/src/response-utils.ts +28 -0
  64. package/src/reverse.ts +27 -2
  65. package/src/route-definition/dsl-helpers.ts +210 -35
  66. package/src/route-definition/helpers-types.ts +61 -14
  67. package/src/route-definition/index.ts +3 -0
  68. package/src/route-definition/redirect.ts +9 -1
  69. package/src/route-definition/resolve-handler-use.ts +155 -0
  70. package/src/route-types.ts +18 -0
  71. package/src/router/content-negotiation.ts +100 -1
  72. package/src/router/handler-context.ts +70 -17
  73. package/src/router/intercept-resolution.ts +9 -4
  74. package/src/router/lazy-includes.ts +6 -6
  75. package/src/router/loader-resolution.ts +153 -21
  76. package/src/router/manifest.ts +22 -13
  77. package/src/router/match-api.ts +127 -192
  78. package/src/router/match-middleware/cache-lookup.ts +28 -8
  79. package/src/router/match-middleware/segment-resolution.ts +53 -0
  80. package/src/router/match-result.ts +82 -4
  81. package/src/router/middleware-types.ts +2 -28
  82. package/src/router/middleware.ts +32 -7
  83. package/src/router/navigation-snapshot.ts +182 -0
  84. package/src/router/pattern-matching.ts +60 -9
  85. package/src/router/prerender-match.ts +110 -10
  86. package/src/router/preview-match.ts +30 -102
  87. package/src/router/request-classification.ts +310 -0
  88. package/src/router/route-snapshot.ts +245 -0
  89. package/src/router/router-interfaces.ts +36 -4
  90. package/src/router/router-options.ts +37 -11
  91. package/src/router/segment-resolution/fresh.ts +70 -5
  92. package/src/router/segment-resolution/revalidation.ts +87 -9
  93. package/src/router/trie-matching.ts +10 -4
  94. package/src/router/url-params.ts +49 -0
  95. package/src/router.ts +54 -7
  96. package/src/rsc/handler.ts +478 -399
  97. package/src/rsc/helpers.ts +69 -41
  98. package/src/rsc/loader-fetch.ts +18 -3
  99. package/src/rsc/manifest-init.ts +5 -1
  100. package/src/rsc/progressive-enhancement.ts +14 -3
  101. package/src/rsc/response-route-handler.ts +14 -1
  102. package/src/rsc/rsc-rendering.ts +15 -2
  103. package/src/rsc/server-action.ts +10 -2
  104. package/src/rsc/ssr-setup.ts +2 -2
  105. package/src/rsc/types.ts +6 -4
  106. package/src/segment-content-promise.ts +67 -0
  107. package/src/segment-loader-promise.ts +122 -0
  108. package/src/segment-system.tsx +11 -61
  109. package/src/server/context.ts +65 -5
  110. package/src/server/handle-store.ts +19 -0
  111. package/src/server/loader-registry.ts +9 -8
  112. package/src/server/request-context.ts +142 -55
  113. package/src/ssr/index.tsx +3 -0
  114. package/src/static-handler.ts +18 -6
  115. package/src/types/cache-types.ts +4 -4
  116. package/src/types/handler-context.ts +17 -43
  117. package/src/types/loader-types.ts +37 -11
  118. package/src/types/request-scope.ts +126 -0
  119. package/src/types/route-entry.ts +12 -1
  120. package/src/types/segments.ts +1 -1
  121. package/src/urls/include-helper.ts +24 -14
  122. package/src/urls/path-helper-types.ts +39 -6
  123. package/src/urls/path-helper.ts +47 -12
  124. package/src/urls/pattern-types.ts +12 -0
  125. package/src/urls/response-types.ts +18 -16
  126. package/src/use-loader.tsx +77 -5
  127. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  128. package/src/vite/discovery/discover-routers.ts +5 -1
  129. package/src/vite/discovery/prerender-collection.ts +128 -74
  130. package/src/vite/discovery/state.ts +13 -4
  131. package/src/vite/index.ts +4 -0
  132. package/src/vite/plugin-types.ts +60 -5
  133. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  134. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  135. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  136. package/src/vite/plugins/expose-id-utils.ts +12 -0
  137. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  138. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  139. package/src/vite/plugins/performance-tracks.ts +64 -206
  140. package/src/vite/plugins/refresh-cmd.ts +88 -26
  141. package/src/vite/rango.ts +40 -18
  142. package/src/vite/router-discovery.ts +237 -37
  143. package/src/vite/utils/banner.ts +1 -1
  144. package/src/vite/utils/package-resolution.ts +1 -1
  145. package/src/vite/utils/prerender-utils.ts +37 -5
  146. package/src/vite/utils/shared-utils.ts +3 -2
  147. package/src/browser/debug-channel.ts +0 -93
@@ -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;
@@ -125,6 +125,69 @@ export async function collectSegments(
125
125
  return segments;
126
126
  }
127
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
+
128
191
  /**
129
192
  * Build the final MatchResult from collected segments and context
130
193
  */
@@ -181,6 +244,11 @@ export function buildMatchResult<TEnv>(
181
244
  );
182
245
  }
183
246
 
247
+ const dedupedSegments = deduplicateLoaderSegments(
248
+ segmentsToRender,
249
+ logPrefix,
250
+ );
251
+
184
252
  debugLog(logPrefix, "all segments", {
185
253
  segments: allSegments.map((s) => ({
186
254
  id: s.id,
@@ -189,13 +257,23 @@ export function buildMatchResult<TEnv>(
189
257
  })),
190
258
  });
191
259
  debugLog(logPrefix, "segments to render", {
192
- segmentIds: segmentsToRender.map((s) => s.id),
260
+ segmentIds: dedupedSegments.map((s) => s.id),
193
261
  });
194
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
+
195
273
  return {
196
- segments: segmentsToRender,
197
- matched: allIds,
198
- diff: segmentsToRender.map((s) => s.id),
274
+ segments: dedupedSegments,
275
+ matched: matchedIds,
276
+ diff: dedupedSegments.map((s) => s.id),
199
277
  params: ctx.matched.params,
200
278
  routeName: ctx.routeKey,
201
279
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
@@ -14,6 +14,7 @@ import type {
14
14
  import type { ScopedReverseFunction } from "../reverse.js";
15
15
  import type { Theme } from "../theme/types.js";
16
16
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
17
+ import type { RequestScope } from "../types/request-scope.js";
17
18
 
18
19
  /**
19
20
  * Get variable function type
@@ -57,28 +58,7 @@ export interface CookieOptions {
57
58
  export interface MiddlewareContext<
58
59
  TEnv = any,
59
60
  TParams = Record<string, string>,
60
- > {
61
- /** Original request */
62
- request: Request;
63
-
64
- /** Parsed URL (with internal `_rsc*` params stripped) */
65
- url: URL;
66
-
67
- /**
68
- * The original request URL with all parameters intact, including
69
- * internal `_rsc*` transport params.
70
- */
71
- originalUrl: URL;
72
-
73
- /** URL pathname */
74
- pathname: string;
75
-
76
- /** URL search params */
77
- searchParams: URLSearchParams;
78
-
79
- /** Platform bindings (Cloudflare, etc.) */
80
- env: TEnv;
81
-
61
+ > extends RequestScope<TEnv> {
82
62
  /** URL params extracted from route/middleware pattern */
83
63
  params: TParams;
84
64
 
@@ -95,12 +75,6 @@ export interface MiddlewareContext<
95
75
  /** Set a context variable (shared with route handlers) */
96
76
  set: SetVariableFn;
97
77
 
98
- /**
99
- * Middleware-injected variables.
100
- * Same shared dictionary as `ctx.get()`/`ctx.set()`.
101
- */
102
- var: DefaultVars;
103
-
104
78
  /**
105
79
  * Set a response header - can be called before or after `next()`.
106
80
  *
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { contextGet, contextSet } from "../context-var.js";
13
+ import { safeDecodeURIComponent } from "./url-params.js";
14
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
13
15
  import type {
14
16
  CollectedMiddleware,
15
17
  MiddlewareCollectableEntry,
@@ -22,6 +24,7 @@ import { _getRequestContext } from "../server/request-context.js";
22
24
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
25
  import { appendMetric, createMetricsStore } from "./metrics.js";
24
26
  import { stripInternalParams } from "./handler-context.js";
27
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
25
28
 
26
29
  // Re-export types and cookie utilities for backward compatibility
27
30
  export type {
@@ -112,7 +115,12 @@ function escapeRegex(str: string): string {
112
115
  }
113
116
 
114
117
  /**
115
- * Extract params from a pathname using a pattern's regex and param names
118
+ * Extract params from a pathname using a pattern's regex and param names.
119
+ *
120
+ * Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
121
+ * instead of the percent-encoded form ("ivo%40example.com"). This matches the
122
+ * contract assumed by ctx.reverse (which re-encodes) and aligns with
123
+ * Express/React Router/Fastify/Koa.
116
124
  */
117
125
  export function extractParams(
118
126
  pathname: string,
@@ -124,7 +132,7 @@ export function extractParams(
124
132
 
125
133
  const params: Record<string, string> = {};
126
134
  for (let i = 0; i < paramNames.length; i++) {
127
- params[paramNames[i]] = match[i + 1] || "";
135
+ params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
128
136
  }
129
137
  return params;
130
138
  }
@@ -179,14 +187,22 @@ export function createMiddlewareContext<TEnv>(
179
187
  return responseHolder.response;
180
188
  };
181
189
 
190
+ // Capture reqCtx once: the request-scoped platform fields
191
+ // (originalUrl, executionContext, waitUntil) are immutable per request,
192
+ // so snapshotting beats re-reading ALS on every access. The lazy getters
193
+ // below (routeName, theme, setTheme) stay lazy because those can change
194
+ // during `await next()`.
195
+ const reqCtx = _getRequestContext();
182
196
  return {
183
197
  request,
184
198
  url,
185
- originalUrl: new URL(request.url),
199
+ originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
186
200
  pathname: url.pathname,
187
201
  searchParams: url.searchParams,
188
202
  env: env as MiddlewareContext<TEnv>["env"],
189
203
  params,
204
+ executionContext: reqCtx?.executionContext,
205
+ waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
190
206
  // Getter: re-derives from request context on each access so that global
191
207
  // middleware sees the matched route name after await next().
192
208
  get routeName(): MiddlewareContext<TEnv>["routeName"] {
@@ -207,9 +223,6 @@ export function createMiddlewareContext<TEnv>(
207
223
  set: ((keyOrVar: any, value: unknown, options?: any) => {
208
224
  contextSet(variables, keyOrVar, value, options);
209
225
  }) as MiddlewareContext<TEnv>["set"],
210
-
211
- var: variables as MiddlewareContext<TEnv>["var"],
212
-
213
226
  header(name: string, value: string): void {
214
227
  // Before next(): delegate to shared RequestContext stub
215
228
  if (isPreNext()) {
@@ -363,6 +376,11 @@ export async function executeMiddleware<TEnv>(
363
376
  });
364
377
  }
365
378
 
379
+ if (isWebSocketUpgradeResponse(response)) {
380
+ responseHolder.response = response;
381
+ return response;
382
+ }
383
+
366
384
  // Clone response with merged headers (mutable for post-next() modifications)
367
385
  responseHolder.response = new Response(response.body, {
368
386
  status: response.status,
@@ -454,6 +472,10 @@ export async function executeMiddleware<TEnv>(
454
472
  // RequestContext stub headers (from ctx.setCookie) into the
455
473
  // returned Response so they are not lost.
456
474
  if (result instanceof Response) {
475
+ if (isWebSocketUpgradeResponse(result)) {
476
+ responseHolder.response = result;
477
+ return result;
478
+ }
457
479
  const mergedHeaders = new Headers(result.headers);
458
480
  stubResponse.headers.forEach((value, name) => {
459
481
  if (name.toLowerCase() === "set-cookie") {
@@ -530,8 +552,11 @@ export async function executeMiddleware<TEnv>(
530
552
  // last merge point (e.g. cookies().set() called after await next()).
531
553
  // The reqCtx stub may have already been partially merged during finalHandler
532
554
  // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
555
+ //
556
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
557
+ // set-cookie on an upgrade is not meaningful.
533
558
  const reqCtx = _getRequestContext();
534
- if (reqCtx) {
559
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
535
560
  const stubCookies = reqCtx.res.headers.getSetCookie();
536
561
  if (stubCookies.length > 0) {
537
562
  const existingCookies = new Set(finalResponse.headers.getSetCookie());
@@ -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
+ }
@@ -7,6 +7,7 @@
7
7
  import type { RouteEntry, TrailingSlashMode } from "../types";
8
8
  import type { EntryData } from "../server/context";
9
9
  import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
+ import { safeDecodeURIComponent } from "./url-params.js";
10
11
 
11
12
  /**
12
13
  * Parsed segment info
@@ -82,6 +83,13 @@ export interface CompiledPattern {
82
83
  paramNames: string[];
83
84
  optionalParams: Set<string>;
84
85
  hasTrailingSlash: boolean;
86
+ /**
87
+ * Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
88
+ * Validated against the **decoded** param value after regex extraction so
89
+ * a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
90
+ * path's behavior (trie-matching.ts:validateAndBuild).
91
+ */
92
+ constraints?: Record<string, string[]>;
85
93
  }
86
94
 
87
95
  // Module-level cache for compiled patterns. Route patterns are a finite set
@@ -142,6 +150,7 @@ export function compilePattern(pattern: string): CompiledPattern {
142
150
  const segments = parsePattern(normalizedPattern);
143
151
  const paramNames: string[] = [];
144
152
  const optionalParams = new Set<string>();
153
+ let constraints: Record<string, string[]> | undefined;
145
154
 
146
155
  let regexPattern = "";
147
156
 
@@ -152,11 +161,14 @@ export function compilePattern(pattern: string): CompiledPattern {
152
161
  } else if (segment.type === "param") {
153
162
  paramNames.push(segment.value);
154
163
  const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
155
- const valuePattern = segment.constraint
156
- ? `(${segment.constraint.map(escapeRegex).join("|")})`
157
- : segment.suffix
158
- ? "([^/]+?)"
159
- : "([^/]+)";
164
+ // Constrained params capture anything here; the allowed values are
165
+ // checked post-decode in findMatch so URL-encoded constraint values
166
+ // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
167
+ const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
168
+
169
+ if (segment.constraint) {
170
+ (constraints ??= {})[segment.value] = segment.constraint;
171
+ }
160
172
 
161
173
  if (segment.optional) {
162
174
  optionalParams.add(segment.value);
@@ -186,9 +198,33 @@ export function compilePattern(pattern: string): CompiledPattern {
186
198
  paramNames,
187
199
  optionalParams,
188
200
  hasTrailingSlash,
201
+ ...(constraints ? { constraints } : {}),
189
202
  };
190
203
  }
191
204
 
205
+ /**
206
+ * Validate decoded params against a compiled pattern's constraints.
207
+ * Returns false if any constrained param has a non-empty value not in the
208
+ * allowed list (empty-string = absent optional, which is allowed).
209
+ */
210
+ function satisfiesConstraints(
211
+ params: Record<string, string>,
212
+ constraints: Record<string, string[]> | undefined,
213
+ ): boolean {
214
+ if (!constraints) return true;
215
+ for (const name in constraints) {
216
+ const value = params[name];
217
+ if (
218
+ value !== undefined &&
219
+ value !== "" &&
220
+ !constraints[name].includes(value)
221
+ ) {
222
+ return false;
223
+ }
224
+ }
225
+ return true;
226
+ }
227
+
192
228
  /**
193
229
  * Escape special regex characters in a string
194
230
  */
@@ -392,8 +428,13 @@ export function findMatch<TEnv>(
392
428
  fullPattern = entry.prefix + pattern;
393
429
  }
394
430
 
395
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
396
- getCompiledPattern(fullPattern);
431
+ const {
432
+ regex,
433
+ paramNames,
434
+ optionalParams,
435
+ hasTrailingSlash,
436
+ constraints,
437
+ } = getCompiledPattern(fullPattern);
397
438
 
398
439
  // Get trailing slash mode for this route (per-route config or pattern-based)
399
440
  const trailingSlashMode: TrailingSlashMode | undefined =
@@ -412,9 +453,15 @@ export function findMatch<TEnv>(
412
453
  if (match) {
413
454
  const params: Record<string, string> = {};
414
455
  paramNames.forEach((name, index) => {
415
- params[name] = match[index + 1] ?? "";
456
+ params[name] = safeDecodeURIComponent(match[index + 1] ?? "");
416
457
  });
417
458
 
459
+ // Validate constraints against decoded values; a failure falls
460
+ // through to the next route so other patterns can still match.
461
+ if (!satisfiesConstraints(params, constraints)) {
462
+ continue;
463
+ }
464
+
418
465
  if (effectiveDebug) {
419
466
  debugLog("findMatch", "matched route", {
420
467
  routeKey,
@@ -467,9 +514,13 @@ export function findMatch<TEnv>(
467
514
  if (altMatch) {
468
515
  const params: Record<string, string> = {};
469
516
  paramNames.forEach((name, index) => {
470
- params[name] = altMatch[index + 1] ?? "";
517
+ params[name] = safeDecodeURIComponent(altMatch[index + 1] ?? "");
471
518
  });
472
519
 
520
+ if (!satisfiesConstraints(params, constraints)) {
521
+ continue;
522
+ }
523
+
473
524
  // Determine redirect behavior based on mode
474
525
  if (trailingSlashMode === "ignore") {
475
526
  // Match without redirect