@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.bf1b128c

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 (103) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +1338 -462
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +362 -0
  7. package/skills/hooks/SKILL.md +28 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +88 -16
  11. package/skills/loader/SKILL.md +66 -2
  12. package/skills/middleware/SKILL.md +32 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +765 -0
  15. package/skills/parallel/SKILL.md +66 -0
  16. package/skills/rango/SKILL.md +24 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/streams-and-websockets/SKILL.md +283 -0
  20. package/skills/typesafety/SKILL.md +3 -1
  21. package/src/browser/app-shell.ts +52 -0
  22. package/src/browser/navigation-bridge.ts +71 -5
  23. package/src/browser/navigation-client.ts +64 -13
  24. package/src/browser/navigation-store.ts +25 -1
  25. package/src/browser/partial-update.ts +34 -3
  26. package/src/browser/prefetch/cache.ts +129 -21
  27. package/src/browser/prefetch/fetch.ts +148 -16
  28. package/src/browser/prefetch/queue.ts +36 -5
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/Link.tsx +30 -2
  31. package/src/browser/react/NavigationProvider.tsx +50 -11
  32. package/src/browser/react/use-navigation.ts +22 -2
  33. package/src/browser/react/use-params.ts +11 -1
  34. package/src/browser/react/use-router.ts +8 -1
  35. package/src/browser/rsc-router.tsx +34 -6
  36. package/src/browser/segment-reconciler.ts +36 -14
  37. package/src/browser/types.ts +13 -0
  38. package/src/build/route-trie.ts +50 -24
  39. package/src/cache/cf/cf-cache-store.ts +5 -7
  40. package/src/client.tsx +82 -174
  41. package/src/index.rsc.ts +3 -0
  42. package/src/index.ts +40 -9
  43. package/src/outlet-context.ts +1 -1
  44. package/src/response-utils.ts +28 -0
  45. package/src/reverse.ts +7 -3
  46. package/src/route-definition/dsl-helpers.ts +175 -23
  47. package/src/route-definition/helpers-types.ts +63 -14
  48. package/src/route-definition/resolve-handler-use.ts +6 -0
  49. package/src/route-types.ts +7 -0
  50. package/src/router/handler-context.ts +24 -4
  51. package/src/router/lazy-includes.ts +6 -6
  52. package/src/router/loader-resolution.ts +3 -0
  53. package/src/router/manifest.ts +22 -13
  54. package/src/router/match-api.ts +3 -3
  55. package/src/router/middleware-types.ts +2 -22
  56. package/src/router/middleware.ts +54 -7
  57. package/src/router/pattern-matching.ts +60 -9
  58. package/src/router/revalidation.ts +15 -1
  59. package/src/router/segment-resolution/revalidation.ts +63 -58
  60. package/src/router/trie-matching.ts +10 -4
  61. package/src/router/url-params.ts +49 -0
  62. package/src/router.ts +1 -2
  63. package/src/rsc/handler.ts +8 -4
  64. package/src/rsc/helpers.ts +69 -41
  65. package/src/rsc/progressive-enhancement.ts +2 -0
  66. package/src/rsc/response-route-handler.ts +14 -1
  67. package/src/rsc/rsc-rendering.ts +7 -0
  68. package/src/rsc/server-action.ts +2 -0
  69. package/src/segment-content-promise.ts +67 -0
  70. package/src/segment-loader-promise.ts +122 -0
  71. package/src/segment-system.tsx +11 -61
  72. package/src/server/context.ts +26 -3
  73. package/src/server/request-context.ts +10 -42
  74. package/src/types/handler-context.ts +12 -39
  75. package/src/types/loader-types.ts +5 -6
  76. package/src/types/request-scope.ts +126 -0
  77. package/src/types/route-entry.ts +11 -0
  78. package/src/types/segments.ts +0 -1
  79. package/src/urls/include-helper.ts +24 -14
  80. package/src/urls/path-helper-types.ts +30 -4
  81. package/src/urls/response-types.ts +2 -10
  82. package/src/vite/debug.ts +184 -0
  83. package/src/vite/discovery/discover-routers.ts +31 -3
  84. package/src/vite/discovery/gate-state.ts +171 -0
  85. package/src/vite/discovery/prerender-collection.ts +48 -1
  86. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  87. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  88. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  89. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  90. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  91. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  92. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  93. package/src/vite/plugins/expose-action-id.ts +52 -28
  94. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  95. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  96. package/src/vite/plugins/performance-tracks.ts +17 -9
  97. package/src/vite/plugins/use-cache-transform.ts +56 -43
  98. package/src/vite/plugins/version-injector.ts +37 -11
  99. package/src/vite/rango.ts +49 -14
  100. package/src/vite/router-discovery.ts +558 -53
  101. package/src/vite/utils/banner.ts +1 -1
  102. package/src/vite/utils/package-resolution.ts +41 -1
  103. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -1,7 +1,7 @@
1
1
  import { registerRouteMap } from "../route-map-builder.js";
2
2
  import { extractStaticPrefix } from "./pattern-matching.js";
3
3
  import {
4
- EntryData,
4
+ type EntryData,
5
5
  RSCRouterContext,
6
6
  runWithPrefixes,
7
7
  getIsolatedLazyParent,
@@ -125,9 +125,8 @@ export function evaluateLazyEntry<TEnv = any>(
125
125
  // Merge captured counters from include() to maintain consistent
126
126
  // shortCode indices with sibling entries from pattern extraction
127
127
  const lazyCounters: Record<string, number> = {};
128
- if (lazyContext && (lazyContext as any).counters) {
129
- const captured = (lazyContext as any).counters as Record<string, number>;
130
- for (const [key, value] of Object.entries(captured)) {
128
+ if (lazyContext?.counters) {
129
+ for (const [key, value] of Object.entries(lazyContext.counters)) {
131
130
  lazyCounters[key] = value;
132
131
  }
133
132
  }
@@ -141,8 +140,9 @@ export function evaluateLazyEntry<TEnv = any>(
141
140
  namespace: "lazy",
142
141
  parent: getIsolatedLazyParent(lazyContext?.parent as EntryData | null),
143
142
  counters: lazyCounters,
144
- cacheProfiles: (lazyContext as any)?.cacheProfiles,
145
- rootScoped: (lazyContext as any)?.rootScoped,
143
+ cacheProfiles: lazyContext?.cacheProfiles,
144
+ rootScoped: lazyContext?.rootScoped,
145
+ includeScope: lazyContext?.includeScope,
146
146
  },
147
147
  () => {
148
148
  // Run the lazy patterns handler with the original context prefixes
@@ -266,7 +266,10 @@ function createLoaderExecutor<TEnv>(
266
266
  search: (ctx as any).search,
267
267
  pathname: ctx.pathname,
268
268
  url: ctx.url,
269
+ originalUrl: ctx.originalUrl,
269
270
  env: ctx.env,
271
+ waitUntil: ctx.waitUntil.bind(ctx),
272
+ executionContext: ctx.executionContext,
270
273
  get: ((keyOrVar: any) =>
271
274
  contextGet(variables, keyOrVar)) as typeof ctx.get,
272
275
  use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
@@ -126,28 +126,37 @@ export async function loadManifest(
126
126
  // were created during pattern extraction. This prevents shortCode
127
127
  // collisions between lazy and non-lazy entries under the same parent
128
128
  // (e.g., ArticlesLayout and BlogLayout both under NavLayout).
129
- if (lazyContext && (lazyContext as any).counters) {
130
- const captured = (lazyContext as any).counters as Record<string, number>;
131
- for (const [key, value] of Object.entries(captured)) {
129
+ if (lazyContext?.counters) {
130
+ for (const [key, value] of Object.entries(lazyContext.counters)) {
132
131
  Store.counters[key] = Math.max(Store.counters[key] ?? 0, value);
133
132
  }
134
133
  }
135
134
 
136
135
  // Propagate cache profiles for DSL-time cache("profileName") resolution.
137
136
  // Non-lazy entries carry profiles directly; lazy entries carry them
138
- // in the captured lazyContext from include() time.
139
- const entryProfiles =
140
- entry.cacheProfiles ?? (lazyContext as any)?.cacheProfiles;
141
- if (entryProfiles) {
142
- Store.cacheProfiles = entryProfiles;
143
- }
137
+ // in the captured lazyContext from include() time. Always write
138
+ // (including clearing to undefined) so a prior lazy build's profile
139
+ // map cannot leak into a later non-lazy build on the same ALS-backed
140
+ // Store — which would otherwise let cache("name") resolve a profile
141
+ // from an unrelated entry.
142
+ Store.cacheProfiles = entry.cacheProfiles ?? lazyContext?.cacheProfiles;
144
143
 
145
144
  // Propagate rootScoped from lazyContext so that routes inside
146
145
  // nested { name: "sub" } under { name: "" } keep inherited root scope
147
- // when the manifest is rebuilt on each request.
148
- if (lazyContext && (lazyContext as any).rootScoped !== undefined) {
149
- Store.rootScoped = (lazyContext as any).rootScoped;
150
- }
146
+ // when the manifest is rebuilt on each request. Always write
147
+ // (including clearing to undefined, which makes getRootScoped()
148
+ // return its true default) so a prior lazy build's scope cannot leak
149
+ // into a later non-lazy build on the same ALS-backed Store — which
150
+ // would otherwise mis-register plain routes as non-root-scoped and
151
+ // break dot-local reverse resolution.
152
+ Store.rootScoped = lazyContext?.rootScoped;
153
+
154
+ // Propagate includeScope from lazyContext so that direct-descendant
155
+ // shortCodes of this include use the correct scoped counter namespace
156
+ // on every manifest rebuild. Always write (including clearing to
157
+ // undefined) so a prior lazy build's scope cannot leak into a later
158
+ // non-lazy build on the same ALS-backed Store.
159
+ Store.includeScope = lazyContext?.includeScope;
151
160
 
152
161
  const handlerExecStart = performance.now();
153
162
  const useItems = await getContext().runWithStore(
@@ -22,10 +22,10 @@ import { collectRouteMiddleware } from "./middleware.js";
22
22
  import { traverseBack } from "./pattern-matching.js";
23
23
  import { DefaultErrorFallback } from "../default-error-boundary.js";
24
24
  import {
25
- EntryData,
26
- LoaderEntry,
25
+ type EntryData,
26
+ type LoaderEntry,
27
27
  getContext,
28
- InterceptSelectorContext,
28
+ type InterceptSelectorContext,
29
29
  } from "../server/context";
30
30
  import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types";
31
31
  import type { ReactNode } from "react";
@@ -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
 
@@ -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"] {
@@ -360,6 +376,11 @@ export async function executeMiddleware<TEnv>(
360
376
  });
361
377
  }
362
378
 
379
+ if (isWebSocketUpgradeResponse(response)) {
380
+ responseHolder.response = response;
381
+ return response;
382
+ }
383
+
363
384
  // Clone response with merged headers (mutable for post-next() modifications)
364
385
  responseHolder.response = new Response(response.body, {
365
386
  status: response.status,
@@ -426,8 +447,16 @@ export async function executeMiddleware<TEnv>(
426
447
  try {
427
448
  result = await entry.handler(ctx, wrappedNext);
428
449
  } catch (error) {
429
- finishMiddleware();
430
- throw error;
450
+ // Thrown Response is short-circuit control flow, not an error.
451
+ // Fall through to the `if (result instanceof Response)` branch below
452
+ // so stub headers and request-context cookies merge as they do for
453
+ // an explicit `return new Response(...)`. Real errors propagate.
454
+ if (error instanceof Response) {
455
+ result = error;
456
+ } else {
457
+ finishMiddleware();
458
+ throw error;
459
+ }
431
460
  }
432
461
  finishMiddleware();
433
462
 
@@ -451,6 +480,10 @@ export async function executeMiddleware<TEnv>(
451
480
  // RequestContext stub headers (from ctx.setCookie) into the
452
481
  // returned Response so they are not lost.
453
482
  if (result instanceof Response) {
483
+ if (isWebSocketUpgradeResponse(result)) {
484
+ responseHolder.response = result;
485
+ return result;
486
+ }
454
487
  const mergedHeaders = new Headers(result.headers);
455
488
  stubResponse.headers.forEach((value, name) => {
456
489
  if (name.toLowerCase() === "set-cookie") {
@@ -527,8 +560,11 @@ export async function executeMiddleware<TEnv>(
527
560
  // last merge point (e.g. cookies().set() called after await next()).
528
561
  // The reqCtx stub may have already been partially merged during finalHandler
529
562
  // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
563
+ //
564
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
565
+ // set-cookie on an upgrade is not meaningful.
530
566
  const reqCtx = _getRequestContext();
531
- if (reqCtx) {
567
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
532
568
  const stubCookies = reqCtx.res.headers.getSetCookie();
533
569
  if (stubCookies.length > 0) {
534
570
  const existingCookies = new Set(finalResponse.headers.getSetCookie());
@@ -613,7 +649,18 @@ export async function executeInterceptMiddleware<TEnv>(
613
649
  return next();
614
650
  };
615
651
 
616
- const result = await middleware(ctx, guardedNext);
652
+ let result: Response | void;
653
+ try {
654
+ result = await middleware(ctx, guardedNext);
655
+ } catch (error) {
656
+ // Thrown Response is short-circuit control flow, parity with the
657
+ // explicit-return path below. Real errors propagate.
658
+ if (error instanceof Response) {
659
+ result = error;
660
+ } else {
661
+ throw error;
662
+ }
663
+ }
617
664
 
618
665
  if (result instanceof Response) {
619
666
  earlyResponse = result;
@@ -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
@@ -59,6 +59,14 @@ interface EvaluateRevalidationOptions<TEnv> {
59
59
  stale?: boolean;
60
60
  /** Trace source hint for the revalidation trace */
61
61
  traceSource?: RevalidationTraceEntry["source"];
62
+ /**
63
+ * Override the segment-type-derived default. When set, the value is used as
64
+ * the seed `defaultShouldRevalidate` passed to user revalidate fns and the
65
+ * reason flows into the trace. Callers use this when client-knowledge
66
+ * (e.g. parallel slot not in clientSegmentIds) should dictate the seed
67
+ * instead of the params/method-based heuristic.
68
+ */
69
+ defaultOverride?: { value: boolean; reason: string };
62
70
  }
63
71
 
64
72
  /**
@@ -81,6 +89,7 @@ export async function evaluateRevalidation<TEnv>(
81
89
  actionContext,
82
90
  stale,
83
91
  traceSource,
92
+ defaultOverride,
84
93
  } = options;
85
94
  const nextParams = segment.params || {};
86
95
  const paramsChanged = !paramsEqual(nextParams, prevParams);
@@ -110,7 +119,12 @@ export async function evaluateRevalidation<TEnv>(
110
119
  let defaultShouldRevalidate: boolean;
111
120
  let defaultReason: string;
112
121
 
113
- if (request.method === "POST") {
122
+ if (defaultOverride) {
123
+ // Caller injected the seed (e.g. parallel slot not in clientSegmentIds).
124
+ // Skip the type-derived heuristic — caller knows better in this context.
125
+ defaultShouldRevalidate = defaultOverride.value;
126
+ defaultReason = defaultOverride.reason;
127
+ } else if (request.method === "POST") {
114
128
  // Actions: revalidate segments that belong to the route, skip parent chain
115
129
  if (segment.type === "route") {
116
130
  // Route segment always revalidates on actions
@@ -89,6 +89,27 @@ function observeStreamedHandler(
89
89
  });
90
90
  }
91
91
 
92
+ /**
93
+ * Trace a parallel slot that's being force-rendered on a full refetch (client
94
+ * has no cached state). User revalidate fns are bypassed in this case — see
95
+ * the call sites for the load-bearing rationale.
96
+ */
97
+ function traceFullRefetchedParallelSlot(
98
+ parallelId: string,
99
+ belongsToRoute: boolean,
100
+ ): void {
101
+ if (!isTraceActive()) return;
102
+ pushRevalidationTraceEntry({
103
+ segmentId: parallelId,
104
+ segmentType: "parallel",
105
+ belongsToRoute,
106
+ source: "parallel",
107
+ defaultShouldRevalidate: true,
108
+ finalShouldRevalidate: true,
109
+ reason: "full-refetch",
110
+ });
111
+ }
112
+
92
113
  // ---------------------------------------------------------------------------
93
114
  // Revalidation telemetry helper
94
115
  // ---------------------------------------------------------------------------
@@ -448,44 +469,30 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
448
469
 
449
470
  const isFullRefetch = clientSegmentIds.size === 0;
450
471
  const isNewParent = !clientSegmentIds.has(entry.shortCode);
451
- if (
452
- isFullRefetch ||
453
- clientSegmentIds.has(parallelId) ||
454
- belongsToRoute ||
455
- isNewParent
456
- ) {
457
- matchedIds.push(parallelId);
458
- }
472
+ // Always announce the slot in matchedIds — it's unconditionally appended
473
+ // to `segments` below, and a segment present in segments but missing from
474
+ // matched lets the client prune it (then it's missing from clientSegmentIds
475
+ // on the next request, perpetuating the staleness).
476
+ matchedIds.push(parallelId);
459
477
 
460
- const shouldResolve = await (async () => {
461
- if (isFullRefetch) {
462
- if (isTraceActive()) {
463
- pushRevalidationTraceEntry({
464
- segmentId: parallelId,
465
- segmentType: "parallel",
466
- belongsToRoute,
467
- source: "parallel",
468
- defaultShouldRevalidate: true,
469
- finalShouldRevalidate: true,
470
- reason: "full-refetch",
471
- });
472
- }
473
- return true;
474
- }
478
+ let shouldResolve: boolean;
479
+ if (isFullRefetch) {
480
+ // Client has nothing cached — slot MUST render. User revalidate fns are
481
+ // bypassed here because returning false would leave the segment blank
482
+ // with no client-side fallback.
483
+ traceFullRefetchedParallelSlot(parallelId, belongsToRoute);
484
+ shouldResolve = true;
485
+ } else {
486
+ // For non-empty client sets, consult user revalidate fns. When the slot
487
+ // is unknown to the client, override the type-derived default so the
488
+ // soft chain seeds with the right "new segment" / "parent-chain" value.
489
+ let defaultOverride: { value: boolean; reason: string } | undefined;
475
490
  if (!clientSegmentIds.has(parallelId)) {
476
- const result = belongsToRoute || isNewParent;
477
- if (isTraceActive()) {
478
- pushRevalidationTraceEntry({
479
- segmentId: parallelId,
480
- segmentType: "parallel",
481
- belongsToRoute,
482
- source: "parallel",
483
- defaultShouldRevalidate: result,
484
- finalShouldRevalidate: result,
485
- reason: result ? "new-segment" : "skip-parent-chain",
486
- });
487
- }
488
- return result;
491
+ const value = belongsToRoute || isNewParent;
492
+ defaultOverride = {
493
+ value,
494
+ reason: value ? "new-segment" : "skip-parent-chain",
495
+ };
489
496
  }
490
497
 
491
498
  const dummySegment: ResolvedSegment = {
@@ -503,7 +510,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
503
510
  : {}),
504
511
  };
505
512
 
506
- return await evaluateRevalidation({
513
+ shouldResolve = await evaluateRevalidation({
507
514
  segment: dummySegment,
508
515
  prevParams,
509
516
  getPrevSegment: null,
@@ -519,8 +526,9 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
519
526
  actionContext,
520
527
  stale,
521
528
  traceSource: "parallel",
529
+ defaultOverride,
522
530
  });
523
- })();
531
+ }
524
532
  emitRevalidationDecision(
525
533
  parallelId,
526
534
  context.pathname,
@@ -868,7 +876,6 @@ export async function resolveSegmentWithRevalidation<TEnv>(
868
876
  prevUrl,
869
877
  nextUrl,
870
878
  routeKey,
871
- loaderPromises,
872
879
  true,
873
880
  deps,
874
881
  actionContext,
@@ -953,7 +960,6 @@ export async function resolveSegmentWithRevalidation<TEnv>(
953
960
  prevUrl,
954
961
  nextUrl,
955
962
  routeKey,
956
- loaderPromises,
957
963
  false,
958
964
  deps,
959
965
  actionContext,
@@ -980,7 +986,6 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
980
986
  prevUrl: URL,
981
987
  nextUrl: URL,
982
988
  routeKey: string,
983
- loaderPromises: Map<string, Promise<any>>,
984
989
  belongsToRoute: boolean,
985
990
  deps: SegmentResolutionDeps<TEnv>,
986
991
  actionContext?: ActionContext,
@@ -1166,21 +1171,20 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1166
1171
  const parallelId = `${orphan.shortCode}.${slot}`;
1167
1172
  matchedIds.push(parallelId);
1168
1173
 
1169
- const shouldResolve = await (async () => {
1170
- if (!clientSegmentIds.has(parallelId)) {
1171
- if (isTraceActive()) {
1172
- pushRevalidationTraceEntry({
1173
- segmentId: parallelId,
1174
- segmentType: "parallel",
1175
- belongsToRoute,
1176
- source: "parallel",
1177
- defaultShouldRevalidate: true,
1178
- finalShouldRevalidate: true,
1179
- reason: "new-segment",
1180
- });
1181
- }
1182
- return true;
1183
- }
1174
+ const isFullRefetch = clientSegmentIds.size === 0;
1175
+ let shouldResolve: boolean;
1176
+ if (isFullRefetch) {
1177
+ // Same load-bearing rationale as the main parallel path: full refetch
1178
+ // means the client has nothing to fall back to, so the slot must render.
1179
+ traceFullRefetchedParallelSlot(parallelId, belongsToRoute);
1180
+ shouldResolve = true;
1181
+ } else {
1182
+ // When slot is unknown to the client, seed the soft chain with `true`
1183
+ // (orphan parallels always belong to the route — we want them rendered
1184
+ // unless the user explicitly opts out via revalidate()).
1185
+ const defaultOverride = clientSegmentIds.has(parallelId)
1186
+ ? undefined
1187
+ : { value: true, reason: "new-segment" };
1184
1188
 
1185
1189
  const dummySegment: ResolvedSegment = {
1186
1190
  id: parallelId,
@@ -1197,7 +1201,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1197
1201
  : {}),
1198
1202
  };
1199
1203
 
1200
- return await evaluateRevalidation({
1204
+ shouldResolve = await evaluateRevalidation({
1201
1205
  segment: dummySegment,
1202
1206
  prevParams,
1203
1207
  getPrevSegment: null,
@@ -1213,8 +1217,9 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1213
1217
  actionContext,
1214
1218
  stale,
1215
1219
  traceSource: "parallel",
1220
+ defaultOverride,
1216
1221
  });
1217
- })();
1222
+ }
1218
1223
  emitRevalidationDecision(
1219
1224
  parallelId,
1220
1225
  context.pathname,