@rangojs/router 0.0.0-experimental.83 → 0.0.0-experimental.8332dbe4

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 (100) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1197 -454
  3. package/package.json +4 -2
  4. package/skills/breadcrumbs/SKILL.md +3 -1
  5. package/skills/handler-use/SKILL.md +2 -0
  6. package/skills/hooks/SKILL.md +30 -2
  7. package/skills/i18n/SKILL.md +276 -0
  8. package/skills/intercept/SKILL.md +25 -0
  9. package/skills/layout/SKILL.md +2 -0
  10. package/skills/links/SKILL.md +234 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +2 -0
  13. package/skills/migrate-nextjs/SKILL.md +3 -1
  14. package/skills/migrate-react-router/SKILL.md +4 -0
  15. package/skills/parallel/SKILL.md +9 -0
  16. package/skills/rango/SKILL.md +2 -0
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +9 -1
  22. package/skills/view-transitions/SKILL.md +212 -0
  23. package/src/browser/app-shell.ts +52 -0
  24. package/src/browser/event-controller.ts +44 -4
  25. package/src/browser/navigation-bridge.ts +113 -6
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +44 -10
  28. package/src/browser/prefetch/cache.ts +16 -0
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/NavigationProvider.tsx +64 -16
  31. package/src/browser/react/filter-segment-order.ts +51 -7
  32. package/src/browser/react/index.ts +3 -0
  33. package/src/browser/react/use-params.ts +8 -5
  34. package/src/browser/react/use-reverse.ts +99 -0
  35. package/src/browser/react/use-router.ts +8 -1
  36. package/src/browser/react/use-segments.ts +11 -8
  37. package/src/browser/rsc-router.tsx +34 -6
  38. package/src/browser/types.ts +19 -0
  39. package/src/build/route-trie.ts +2 -1
  40. package/src/cache/cf/cf-cache-store.ts +5 -7
  41. package/src/client.rsc.tsx +3 -0
  42. package/src/client.tsx +5 -1
  43. package/src/href-client.ts +4 -1
  44. package/src/index.rsc.ts +3 -0
  45. package/src/index.ts +3 -0
  46. package/src/outlet-context.ts +1 -1
  47. package/src/response-utils.ts +28 -0
  48. package/src/reverse.ts +62 -39
  49. package/src/route-definition/dsl-helpers.ts +16 -3
  50. package/src/route-definition/helpers-types.ts +6 -1
  51. package/src/route-definition/resolve-handler-use.ts +6 -0
  52. package/src/router/handler-context.ts +21 -41
  53. package/src/router/lazy-includes.ts +1 -1
  54. package/src/router/loader-resolution.ts +3 -0
  55. package/src/router/match-api.ts +4 -3
  56. package/src/router/match-handlers.ts +1 -0
  57. package/src/router/match-result.ts +21 -2
  58. package/src/router/middleware-types.ts +14 -25
  59. package/src/router/middleware.ts +54 -7
  60. package/src/router/pattern-matching.ts +101 -17
  61. package/src/router/revalidation.ts +15 -1
  62. package/src/router/segment-resolution/fresh.ts +8 -0
  63. package/src/router/segment-resolution/revalidation.ts +128 -100
  64. package/src/router/substitute-pattern-params.ts +56 -0
  65. package/src/router/trie-matching.ts +18 -13
  66. package/src/router/url-params.ts +49 -0
  67. package/src/router.ts +1 -2
  68. package/src/rsc/handler.ts +8 -4
  69. package/src/rsc/progressive-enhancement.ts +2 -0
  70. package/src/rsc/response-route-handler.ts +11 -10
  71. package/src/rsc/rsc-rendering.ts +3 -0
  72. package/src/rsc/server-action.ts +2 -0
  73. package/src/rsc/types.ts +6 -0
  74. package/src/segment-system.tsx +60 -9
  75. package/src/server/request-context.ts +10 -42
  76. package/src/ssr/index.tsx +5 -1
  77. package/src/types/handler-context.ts +12 -39
  78. package/src/types/loader-types.ts +5 -6
  79. package/src/types/request-scope.ts +126 -0
  80. package/src/types/segments.ts +17 -0
  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/expose-action-id.ts +52 -28
  91. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  92. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  93. package/src/vite/plugins/performance-tracks.ts +17 -9
  94. package/src/vite/plugins/use-cache-transform.ts +56 -43
  95. package/src/vite/plugins/version-injector.ts +37 -11
  96. package/src/vite/rango.ts +49 -14
  97. package/src/vite/router-discovery.ts +498 -52
  98. package/src/vite/utils/banner.ts +1 -1
  99. package/src/vite/utils/package-resolution.ts +41 -1
  100. package/src/vite/utils/prerender-utils.ts +5 -4
@@ -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);
@@ -176,6 +188,20 @@ export function compilePattern(pattern: string): CompiledPattern {
176
188
  regexPattern = "/";
177
189
  }
178
190
 
191
+ // Patterns of only optional segments (e.g. `/:locale?`, `/:a?/:b?`) need
192
+ // an explicit `/` alternative so a bare `/` matches the absent form. The
193
+ // optional template `(?:/X)?` matches `/X` or empty string, but pathnames
194
+ // are never empty. Arises from `include("/:locale?", routes)` + inner
195
+ // `path("/")`. Skip when an explicit trailing slash already anchors the
196
+ // match.
197
+ const hasOnlyOptionalSegments =
198
+ !hasTrailingSlash &&
199
+ segments.length > 0 &&
200
+ segments.every((segment) => segment.type === "param" && segment.optional);
201
+ if (hasOnlyOptionalSegments) {
202
+ regexPattern = `(?:/|${regexPattern})`;
203
+ }
204
+
179
205
  // Add trailing slash to regex if pattern has one
180
206
  if (hasTrailingSlash) {
181
207
  regexPattern += "/";
@@ -186,9 +212,35 @@ export function compilePattern(pattern: string): CompiledPattern {
186
212
  paramNames,
187
213
  optionalParams,
188
214
  hasTrailingSlash,
215
+ ...(constraints ? { constraints } : {}),
189
216
  };
190
217
  }
191
218
 
219
+ /**
220
+ * Validate decoded params against a compiled pattern's constraints.
221
+ * Returns false if any constrained param has a non-empty value not in the
222
+ * allowed list. Absent optionals (key missing or `undefined`) are allowed;
223
+ * `""` is also tolerated as "absent" so user-provided params or fixtures
224
+ * that pass empty strings explicitly behave the same way.
225
+ */
226
+ function satisfiesConstraints(
227
+ params: Record<string, string>,
228
+ constraints: Record<string, string[]> | undefined,
229
+ ): boolean {
230
+ if (!constraints) return true;
231
+ for (const name in constraints) {
232
+ const value = params[name];
233
+ if (
234
+ value !== undefined &&
235
+ value !== "" &&
236
+ !constraints[name].includes(value)
237
+ ) {
238
+ return false;
239
+ }
240
+ }
241
+ return true;
242
+ }
243
+
192
244
  /**
193
245
  * Escape special regex characters in a string
194
246
  */
@@ -196,6 +248,27 @@ function escapeRegex(str: string): string {
196
248
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
249
  }
198
250
 
251
+ /**
252
+ * Build the named-params record from a regex match. Optional segments that
253
+ * didn't capture leave the corresponding group `undefined`; we skip those
254
+ * keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
255
+ * keeps the runtime aligned with the `ExtractParams` type and matches the
256
+ * trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
257
+ */
258
+ function buildParamsFromMatch(
259
+ match: RegExpExecArray,
260
+ paramNames: string[],
261
+ ): Record<string, string> {
262
+ const params: Record<string, string> = {};
263
+ paramNames.forEach((name, index) => {
264
+ const captured = match[index + 1];
265
+ if (captured !== undefined) {
266
+ params[name] = safeDecodeURIComponent(captured);
267
+ }
268
+ });
269
+ return params;
270
+ }
271
+
199
272
  /**
200
273
  * Extract the static prefix from a route pattern.
201
274
  * Returns everything before the first param/wildcard.
@@ -247,8 +320,10 @@ export function extractStaticPrefix(pattern: string): string {
247
320
  /**
248
321
  * Match a pathname against registered routes
249
322
  *
250
- * Note: Optional params that are absent in the path will have empty string value.
251
- * Use the pattern definition to determine if a param is optional.
323
+ * Note: Optional params that are absent in the path are omitted from the
324
+ * returned `params` (read as `undefined`), matching the trie matcher and
325
+ * the `ExtractParams<"/:locale?/...">` type. Use the pattern definition or
326
+ * `optionalParams` to determine which keys are optional.
252
327
  *
253
328
  * Trailing slash handling (priority order):
254
329
  * 1. Per-route `trailingSlash` config from route()
@@ -392,8 +467,13 @@ export function findMatch<TEnv>(
392
467
  fullPattern = entry.prefix + pattern;
393
468
  }
394
469
 
395
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
396
- getCompiledPattern(fullPattern);
470
+ const {
471
+ regex,
472
+ paramNames,
473
+ optionalParams,
474
+ hasTrailingSlash,
475
+ constraints,
476
+ } = getCompiledPattern(fullPattern);
397
477
 
398
478
  // Get trailing slash mode for this route (per-route config or pattern-based)
399
479
  const trailingSlashMode: TrailingSlashMode | undefined =
@@ -410,10 +490,13 @@ export function findMatch<TEnv>(
410
490
  // Try exact match first
411
491
  const match = regex.exec(pathname);
412
492
  if (match) {
413
- const params: Record<string, string> = {};
414
- paramNames.forEach((name, index) => {
415
- params[name] = match[index + 1] ?? "";
416
- });
493
+ const params = buildParamsFromMatch(match, paramNames);
494
+
495
+ // Validate constraints against decoded values; a failure falls
496
+ // through to the next route so other patterns can still match.
497
+ if (!satisfiesConstraints(params, constraints)) {
498
+ continue;
499
+ }
417
500
 
418
501
  if (effectiveDebug) {
419
502
  debugLog("findMatch", "matched route", {
@@ -465,10 +548,11 @@ export function findMatch<TEnv>(
465
548
  // Try alternate pathname (opposite trailing slash)
466
549
  const altMatch = regex.exec(alternatePathname);
467
550
  if (altMatch) {
468
- const params: Record<string, string> = {};
469
- paramNames.forEach((name, index) => {
470
- params[name] = altMatch[index + 1] ?? "";
471
- });
551
+ const params = buildParamsFromMatch(altMatch, paramNames);
552
+
553
+ if (!satisfiesConstraints(params, constraints)) {
554
+ continue;
555
+ }
472
556
 
473
557
  // Determine redirect behavior based on mode
474
558
  if (trailingSlashMode === "ignore") {
@@ -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
@@ -515,6 +515,14 @@ export async function resolveParallelEntry<TEnv>(
515
515
  if (handler === undefined) {
516
516
  continue;
517
517
  }
518
+ // Pin `_currentSegmentId` to the slot's own id so handle pushes from
519
+ // inside the slot handler get their own bucket in the HandleStore.
520
+ // Parent-keying would collapse them into the parent layout's bucket;
521
+ // the partial-update merge then replaces the parent's bucket on a
522
+ // slot-only revalidation and drops layout-pushed Meta/Breadcrumbs.
523
+ // filterSegmentOrder() retains slot ids so the client preserves them.
524
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
525
+ `${parentShortCode}.${slot}`;
518
526
  const doneParallelHandler = track(
519
527
  `handler:${parallelEntry.id}.${slot}`,
520
528
  2,
@@ -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,
@@ -529,8 +537,11 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
529
537
  );
530
538
 
531
539
  let component: ReactNode | undefined;
540
+ let handlerRan = false;
532
541
  if (shouldResolve) {
533
542
  component = await tryStaticSlot(parallelEntry, slot, parallelId);
543
+ // tryStaticSlot returning a value means the static cache supplied the
544
+ // component — handler did NOT run. handlerRan stays false.
534
545
  }
535
546
  if (component === undefined) {
536
547
  const hasLoadingFallback =
@@ -541,29 +552,37 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
541
552
  // Handler evicted (production static slot) but static lookup missed.
542
553
  // Nothing to render — use null so the client keeps its cached version.
543
554
  component = null;
544
- } else if (hasLoadingFallback) {
545
- const result =
546
- typeof handler === "function" ? handler(context) : handler;
547
- if (result instanceof Promise) {
548
- const tracked = deps.trackHandler(result, {
549
- segmentId: parallelId,
550
- segmentType: "parallel",
551
- });
552
- observeStreamedHandler(
553
- tracked,
554
- parallelId,
555
- "parallel",
556
- context.pathname,
557
- routeKey,
558
- params,
559
- );
560
- component = tracked as ReactNode;
555
+ } else {
556
+ // Slot-keyed pushes — slot owns its own bucket, parent layout owns
557
+ // its own. On slot-only revalidations the partial merge updates only
558
+ // the slot's bucket; the parent's bucket stays intact.
559
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
560
+ parallelId;
561
+ handlerRan = true;
562
+ if (hasLoadingFallback) {
563
+ const result =
564
+ typeof handler === "function" ? handler(context) : handler;
565
+ if (result instanceof Promise) {
566
+ const tracked = deps.trackHandler(result, {
567
+ segmentId: parallelId,
568
+ segmentType: "parallel",
569
+ });
570
+ observeStreamedHandler(
571
+ tracked,
572
+ parallelId,
573
+ "parallel",
574
+ context.pathname,
575
+ routeKey,
576
+ params,
577
+ );
578
+ component = tracked as ReactNode;
579
+ } else {
580
+ component = result as ReactNode;
581
+ }
561
582
  } else {
562
- component = result as ReactNode;
583
+ component =
584
+ typeof handler === "function" ? await handler(context) : handler;
563
585
  }
564
- } else {
565
- component =
566
- typeof handler === "function" ? await handler(context) : handler;
567
586
  }
568
587
  }
569
588
 
@@ -577,6 +596,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
577
596
  transition: parallelEntry.transition,
578
597
  params,
579
598
  slot,
599
+ _handlerRan: handlerRan,
580
600
  belongsToRoute,
581
601
  parallelName: `${parallelEntry.id}.${slot}`,
582
602
  ...(parallelEntry.mountPath
@@ -631,6 +651,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
631
651
  ): Promise<{ segment: ResolvedSegment; matchedId: string }> {
632
652
  const matchedId = entry.shortCode;
633
653
 
654
+ let handlerRan = false;
634
655
  const component = await revalidate(
635
656
  async () => {
636
657
  const hasSegment = clientSegmentIds.has(entry.shortCode);
@@ -707,6 +728,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
707
728
  return shouldRevalidate;
708
729
  },
709
730
  async () => {
731
+ handlerRan = true;
710
732
  const doneHandler = track(`handler:${entry.id}`, 2);
711
733
  (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
712
734
  entry.shortCode;
@@ -788,6 +810,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
788
810
  ? { layoutName: entry.id }
789
811
  : {}),
790
812
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
813
+ _handlerRan: handlerRan,
791
814
  };
792
815
 
793
816
  return { segment, matchedId };
@@ -868,7 +891,6 @@ export async function resolveSegmentWithRevalidation<TEnv>(
868
891
  prevUrl,
869
892
  nextUrl,
870
893
  routeKey,
871
- loaderPromises,
872
894
  true,
873
895
  deps,
874
896
  actionContext,
@@ -953,7 +975,6 @@ export async function resolveSegmentWithRevalidation<TEnv>(
953
975
  prevUrl,
954
976
  nextUrl,
955
977
  routeKey,
956
- loaderPromises,
957
978
  false,
958
979
  deps,
959
980
  actionContext,
@@ -980,7 +1001,6 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
980
1001
  prevUrl: URL,
981
1002
  nextUrl: URL,
982
1003
  routeKey: string,
983
- loaderPromises: Map<string, Promise<any>>,
984
1004
  belongsToRoute: boolean,
985
1005
  deps: SegmentResolutionDeps<TEnv>,
986
1006
  actionContext?: ActionContext,
@@ -1166,21 +1186,20 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1166
1186
  const parallelId = `${orphan.shortCode}.${slot}`;
1167
1187
  matchedIds.push(parallelId);
1168
1188
 
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
- }
1189
+ const isFullRefetch = clientSegmentIds.size === 0;
1190
+ let shouldResolve: boolean;
1191
+ if (isFullRefetch) {
1192
+ // Same load-bearing rationale as the main parallel path: full refetch
1193
+ // means the client has nothing to fall back to, so the slot must render.
1194
+ traceFullRefetchedParallelSlot(parallelId, belongsToRoute);
1195
+ shouldResolve = true;
1196
+ } else {
1197
+ // When slot is unknown to the client, seed the soft chain with `true`
1198
+ // (orphan parallels always belong to the route — we want them rendered
1199
+ // unless the user explicitly opts out via revalidate()).
1200
+ const defaultOverride = clientSegmentIds.has(parallelId)
1201
+ ? undefined
1202
+ : { value: true, reason: "new-segment" };
1184
1203
 
1185
1204
  const dummySegment: ResolvedSegment = {
1186
1205
  id: parallelId,
@@ -1197,7 +1216,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1197
1216
  : {}),
1198
1217
  };
1199
1218
 
1200
- return await evaluateRevalidation({
1219
+ shouldResolve = await evaluateRevalidation({
1201
1220
  segment: dummySegment,
1202
1221
  prevParams,
1203
1222
  getPrevSegment: null,
@@ -1213,8 +1232,9 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1213
1232
  actionContext,
1214
1233
  stale,
1215
1234
  traceSource: "parallel",
1235
+ defaultOverride,
1216
1236
  });
1217
- })();
1237
+ }
1218
1238
  emitRevalidationDecision(
1219
1239
  parallelId,
1220
1240
  context.pathname,
@@ -1223,6 +1243,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1223
1243
  );
1224
1244
 
1225
1245
  let component: ReactNode | undefined;
1246
+ let handlerRan = false;
1226
1247
  if (shouldResolve) {
1227
1248
  component = await tryStaticSlot(parallelEntry, slot, parallelId);
1228
1249
  }
@@ -1234,29 +1255,35 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1234
1255
  } else if (handler === undefined) {
1235
1256
  // Handler evicted (production static slot) but static lookup missed.
1236
1257
  component = null;
1237
- } else if (hasLoadingFallback) {
1238
- const result =
1239
- typeof handler === "function" ? handler(context) : handler;
1240
- if (result instanceof Promise) {
1241
- const tracked = deps.trackHandler(result, {
1242
- segmentId: parallelId,
1243
- segmentType: "parallel",
1244
- });
1245
- observeStreamedHandler(
1246
- tracked,
1247
- parallelId,
1248
- "parallel",
1249
- context.pathname,
1250
- routeKey,
1251
- params,
1252
- );
1253
- component = tracked as ReactNode;
1258
+ } else {
1259
+ // Slot-keyed pushes — see resolveParallelSegmentsWithRevalidation.
1260
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
1261
+ parallelId;
1262
+ handlerRan = true;
1263
+ if (hasLoadingFallback) {
1264
+ const result =
1265
+ typeof handler === "function" ? handler(context) : handler;
1266
+ if (result instanceof Promise) {
1267
+ const tracked = deps.trackHandler(result, {
1268
+ segmentId: parallelId,
1269
+ segmentType: "parallel",
1270
+ });
1271
+ observeStreamedHandler(
1272
+ tracked,
1273
+ parallelId,
1274
+ "parallel",
1275
+ context.pathname,
1276
+ routeKey,
1277
+ params,
1278
+ );
1279
+ component = tracked as ReactNode;
1280
+ } else {
1281
+ component = result as ReactNode;
1282
+ }
1254
1283
  } else {
1255
- component = result as ReactNode;
1284
+ component =
1285
+ typeof handler === "function" ? await handler(context) : handler;
1256
1286
  }
1257
- } else {
1258
- component =
1259
- typeof handler === "function" ? await handler(context) : handler;
1260
1287
  }
1261
1288
  }
1262
1289
 
@@ -1270,6 +1297,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1270
1297
  transition: parallelEntry.transition,
1271
1298
  params,
1272
1299
  slot,
1300
+ _handlerRan: handlerRan,
1273
1301
  belongsToRoute,
1274
1302
  parallelName: `${parallelEntry.id}.${slot}`,
1275
1303
  ...(parallelEntry.mountPath
@@ -0,0 +1,56 @@
1
+ import { encodePathSegment } from "./url-params.js";
2
+
3
+ /**
4
+ * Substitute `:param` placeholders in a route pattern with values from
5
+ * `params`. Two-pass: optional params (`:name?`) first so absent values
6
+ * collapse cleanly, then required params (throws on missing). Constraint
7
+ * syntax (`:name(en|gb)`) is stripped from the result. Trailing-slash
8
+ * patterns like `/blog/` are preserved unless an optional segment was
9
+ * actually omitted.
10
+ *
11
+ * Shared by `ctx.reverse()` (server), `createReverse()` (typed runtime
12
+ * helper), and `useReverse()` (client hook). The behavior must stay
13
+ * identical across all three call sites.
14
+ */
15
+ export function substitutePatternParams(
16
+ pattern: string,
17
+ params: Record<string, string | undefined>,
18
+ routeName: string,
19
+ ): string {
20
+ let result = pattern;
21
+ let hadOmittedOptional = false;
22
+
23
+ result = result.replace(
24
+ /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
25
+ (_match, key) => {
26
+ const value = params[key as string];
27
+ // The matcher omits absent optional params (so `value` is `undefined`
28
+ // here), but caller-supplied params or `getParams()` shapes may still
29
+ // pass `""` explicitly. Treat both as the absent form.
30
+ if (value === undefined || value === "") {
31
+ hadOmittedOptional = true;
32
+ return "";
33
+ }
34
+ return encodePathSegment(value);
35
+ },
36
+ );
37
+
38
+ result = result.replace(
39
+ /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
40
+ (_match, key) => {
41
+ const value = params[key as string];
42
+ if (value === undefined) {
43
+ throw new Error(`Missing param "${key}" for route "${routeName}"`);
44
+ }
45
+ return encodePathSegment(value);
46
+ },
47
+ );
48
+
49
+ if (hadOmittedOptional) {
50
+ const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
51
+ result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
52
+ if (hadTrailingSlash && !result.endsWith("/")) result += "/";
53
+ }
54
+
55
+ return result;
56
+ }