@rangojs/router 0.0.0-experimental.69 → 0.0.0-experimental.6c70a2ab

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 (123) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1456 -467
  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 +364 -0
  7. package/skills/hooks/SKILL.md +54 -20
  8. package/skills/i18n/SKILL.md +276 -0
  9. package/skills/intercept/SKILL.md +45 -0
  10. package/skills/layout/SKILL.md +24 -0
  11. package/skills/links/SKILL.md +234 -16
  12. package/skills/loader/SKILL.md +70 -3
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +562 -0
  15. package/skills/migrate-react-router/SKILL.md +769 -0
  16. package/skills/parallel/SKILL.md +68 -0
  17. package/skills/rango/SKILL.md +26 -22
  18. package/skills/response-routes/SKILL.md +8 -0
  19. package/skills/route/SKILL.md +48 -0
  20. package/skills/server-actions/SKILL.md +739 -0
  21. package/skills/streams-and-websockets/SKILL.md +283 -0
  22. package/skills/typesafety/SKILL.md +9 -1
  23. package/skills/view-transitions/SKILL.md +212 -0
  24. package/src/browser/app-shell.ts +52 -0
  25. package/src/browser/event-controller.ts +44 -4
  26. package/src/browser/navigation-bridge.ts +80 -5
  27. package/src/browser/navigation-client.ts +64 -13
  28. package/src/browser/navigation-store.ts +25 -1
  29. package/src/browser/partial-update.ts +58 -12
  30. package/src/browser/prefetch/cache.ts +129 -21
  31. package/src/browser/prefetch/fetch.ts +148 -16
  32. package/src/browser/prefetch/queue.ts +36 -5
  33. package/src/browser/rango-state.ts +53 -13
  34. package/src/browser/react/Link.tsx +30 -2
  35. package/src/browser/react/NavigationProvider.tsx +70 -18
  36. package/src/browser/react/filter-segment-order.ts +51 -7
  37. package/src/browser/react/index.ts +3 -0
  38. package/src/browser/react/use-navigation.ts +22 -2
  39. package/src/browser/react/use-params.ts +17 -4
  40. package/src/browser/react/use-reverse.ts +99 -0
  41. package/src/browser/react/use-router.ts +8 -1
  42. package/src/browser/react/use-segments.ts +11 -8
  43. package/src/browser/rsc-router.tsx +34 -6
  44. package/src/browser/scroll-restoration.ts +22 -14
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/types.ts +19 -0
  47. package/src/build/route-trie.ts +52 -25
  48. package/src/cache/cf/cf-cache-store.ts +5 -7
  49. package/src/client.rsc.tsx +3 -0
  50. package/src/client.tsx +87 -175
  51. package/src/href-client.ts +4 -1
  52. package/src/index.rsc.ts +3 -0
  53. package/src/index.ts +40 -9
  54. package/src/outlet-context.ts +1 -1
  55. package/src/response-utils.ts +28 -0
  56. package/src/reverse.ts +62 -36
  57. package/src/route-definition/dsl-helpers.ts +175 -23
  58. package/src/route-definition/helpers-types.ts +63 -14
  59. package/src/route-definition/resolve-handler-use.ts +6 -0
  60. package/src/route-types.ts +7 -0
  61. package/src/router/handler-context.ts +21 -38
  62. package/src/router/lazy-includes.ts +6 -6
  63. package/src/router/loader-resolution.ts +3 -0
  64. package/src/router/manifest.ts +22 -13
  65. package/src/router/match-api.ts +4 -3
  66. package/src/router/match-handlers.ts +1 -0
  67. package/src/router/match-middleware/cache-lookup.ts +2 -1
  68. package/src/router/match-result.ts +101 -4
  69. package/src/router/middleware-types.ts +14 -25
  70. package/src/router/middleware.ts +54 -7
  71. package/src/router/pattern-matching.ts +101 -17
  72. package/src/router/revalidation.ts +15 -1
  73. package/src/router/segment-resolution/fresh.ts +13 -0
  74. package/src/router/segment-resolution/revalidation.ts +135 -101
  75. package/src/router/substitute-pattern-params.ts +56 -0
  76. package/src/router/trie-matching.ts +18 -13
  77. package/src/router/url-params.ts +49 -0
  78. package/src/router.ts +1 -2
  79. package/src/rsc/handler.ts +16 -8
  80. package/src/rsc/helpers.ts +69 -41
  81. package/src/rsc/progressive-enhancement.ts +4 -0
  82. package/src/rsc/response-route-handler.ts +14 -1
  83. package/src/rsc/rsc-rendering.ts +10 -0
  84. package/src/rsc/server-action.ts +4 -0
  85. package/src/rsc/types.ts +6 -0
  86. package/src/segment-content-promise.ts +67 -0
  87. package/src/segment-loader-promise.ts +122 -0
  88. package/src/segment-system.tsx +71 -70
  89. package/src/server/context.ts +26 -3
  90. package/src/server/request-context.ts +10 -42
  91. package/src/ssr/index.tsx +5 -1
  92. package/src/types/handler-context.ts +12 -39
  93. package/src/types/loader-types.ts +5 -6
  94. package/src/types/request-scope.ts +126 -0
  95. package/src/types/route-entry.ts +11 -0
  96. package/src/types/segments.ts +18 -1
  97. package/src/urls/include-helper.ts +24 -14
  98. package/src/urls/path-helper-types.ts +30 -4
  99. package/src/urls/response-types.ts +2 -10
  100. package/src/use-loader.tsx +4 -1
  101. package/src/vite/debug.ts +184 -0
  102. package/src/vite/discovery/discover-routers.ts +31 -3
  103. package/src/vite/discovery/gate-state.ts +171 -0
  104. package/src/vite/discovery/prerender-collection.ts +172 -84
  105. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  106. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  107. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  108. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  109. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  110. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  111. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  112. package/src/vite/plugins/expose-action-id.ts +52 -28
  113. package/src/vite/plugins/expose-id-utils.ts +12 -0
  114. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  115. package/src/vite/plugins/expose-internal-ids.ts +540 -376
  116. package/src/vite/plugins/performance-tracks.ts +17 -9
  117. package/src/vite/plugins/use-cache-transform.ts +56 -43
  118. package/src/vite/plugins/version-injector.ts +37 -11
  119. package/src/vite/rango.ts +49 -14
  120. package/src/vite/router-discovery.ts +558 -53
  121. package/src/vite/utils/banner.ts +1 -1
  122. package/src/vite/utils/package-resolution.ts +41 -1
  123. package/src/vite/utils/prerender-utils.ts +21 -6
@@ -419,6 +419,10 @@ export async function resolveOrphanLayout<TEnv>(
419
419
  deps,
420
420
  orphan.shortCode,
421
421
  );
422
+ // Tag as inherited so buildMatchResult can deduplicate when safe
423
+ for (const s of inheritedLoaders) {
424
+ s._inherited = true;
425
+ }
422
426
  segments.push(...inheritedLoaders);
423
427
  }
424
428
  }
@@ -511,6 +515,14 @@ export async function resolveParallelEntry<TEnv>(
511
515
  if (handler === undefined) {
512
516
  continue;
513
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}`;
514
526
  const doneParallelHandler = track(
515
527
  `handler:${parallelEntry.id}.${slot}`,
516
528
  2,
@@ -728,6 +740,7 @@ export async function resolveLoadersOnly<TEnv>(
728
740
  for (const seg of inherited) {
729
741
  if (!seenIds.has(seg.id)) {
730
742
  seenIds.add(seg.id);
743
+ seg._inherited = true;
731
744
  loaderSegments.push(seg);
732
745
  }
733
746
  }
@@ -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
  // ---------------------------------------------------------------------------
@@ -346,6 +367,7 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
346
367
  for (const seg of inherited.segments) {
347
368
  if (!seenIds.has(seg.id)) {
348
369
  seenIds.add(seg.id);
370
+ seg._inherited = true;
349
371
  allLoaderSegments.push(seg);
350
372
  }
351
373
  }
@@ -447,44 +469,30 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
447
469
 
448
470
  const isFullRefetch = clientSegmentIds.size === 0;
449
471
  const isNewParent = !clientSegmentIds.has(entry.shortCode);
450
- if (
451
- isFullRefetch ||
452
- clientSegmentIds.has(parallelId) ||
453
- belongsToRoute ||
454
- isNewParent
455
- ) {
456
- matchedIds.push(parallelId);
457
- }
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);
458
477
 
459
- const shouldResolve = await (async () => {
460
- if (isFullRefetch) {
461
- if (isTraceActive()) {
462
- pushRevalidationTraceEntry({
463
- segmentId: parallelId,
464
- segmentType: "parallel",
465
- belongsToRoute,
466
- source: "parallel",
467
- defaultShouldRevalidate: true,
468
- finalShouldRevalidate: true,
469
- reason: "full-refetch",
470
- });
471
- }
472
- return true;
473
- }
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;
474
490
  if (!clientSegmentIds.has(parallelId)) {
475
- const result = belongsToRoute || isNewParent;
476
- if (isTraceActive()) {
477
- pushRevalidationTraceEntry({
478
- segmentId: parallelId,
479
- segmentType: "parallel",
480
- belongsToRoute,
481
- source: "parallel",
482
- defaultShouldRevalidate: result,
483
- finalShouldRevalidate: result,
484
- reason: result ? "new-segment" : "skip-parent-chain",
485
- });
486
- }
487
- return result;
491
+ const value = belongsToRoute || isNewParent;
492
+ defaultOverride = {
493
+ value,
494
+ reason: value ? "new-segment" : "skip-parent-chain",
495
+ };
488
496
  }
489
497
 
490
498
  const dummySegment: ResolvedSegment = {
@@ -502,7 +510,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
502
510
  : {}),
503
511
  };
504
512
 
505
- return await evaluateRevalidation({
513
+ shouldResolve = await evaluateRevalidation({
506
514
  segment: dummySegment,
507
515
  prevParams,
508
516
  getPrevSegment: null,
@@ -518,8 +526,9 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
518
526
  actionContext,
519
527
  stale,
520
528
  traceSource: "parallel",
529
+ defaultOverride,
521
530
  });
522
- })();
531
+ }
523
532
  emitRevalidationDecision(
524
533
  parallelId,
525
534
  context.pathname,
@@ -528,8 +537,11 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
528
537
  );
529
538
 
530
539
  let component: ReactNode | undefined;
540
+ let handlerRan = false;
531
541
  if (shouldResolve) {
532
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.
533
545
  }
534
546
  if (component === undefined) {
535
547
  const hasLoadingFallback =
@@ -540,29 +552,37 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
540
552
  // Handler evicted (production static slot) but static lookup missed.
541
553
  // Nothing to render — use null so the client keeps its cached version.
542
554
  component = null;
543
- } else if (hasLoadingFallback) {
544
- const result =
545
- typeof handler === "function" ? handler(context) : handler;
546
- if (result instanceof Promise) {
547
- const tracked = deps.trackHandler(result, {
548
- segmentId: parallelId,
549
- segmentType: "parallel",
550
- });
551
- observeStreamedHandler(
552
- tracked,
553
- parallelId,
554
- "parallel",
555
- context.pathname,
556
- routeKey,
557
- params,
558
- );
559
- 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
+ }
560
582
  } else {
561
- component = result as ReactNode;
583
+ component =
584
+ typeof handler === "function" ? await handler(context) : handler;
562
585
  }
563
- } else {
564
- component =
565
- typeof handler === "function" ? await handler(context) : handler;
566
586
  }
567
587
  }
568
588
 
@@ -576,6 +596,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
576
596
  transition: parallelEntry.transition,
577
597
  params,
578
598
  slot,
599
+ _handlerRan: handlerRan,
579
600
  belongsToRoute,
580
601
  parallelName: `${parallelEntry.id}.${slot}`,
581
602
  ...(parallelEntry.mountPath
@@ -630,6 +651,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
630
651
  ): Promise<{ segment: ResolvedSegment; matchedId: string }> {
631
652
  const matchedId = entry.shortCode;
632
653
 
654
+ let handlerRan = false;
633
655
  const component = await revalidate(
634
656
  async () => {
635
657
  const hasSegment = clientSegmentIds.has(entry.shortCode);
@@ -706,6 +728,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
706
728
  return shouldRevalidate;
707
729
  },
708
730
  async () => {
731
+ handlerRan = true;
709
732
  const doneHandler = track(`handler:${entry.id}`, 2);
710
733
  (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
711
734
  entry.shortCode;
@@ -787,6 +810,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
787
810
  ? { layoutName: entry.id }
788
811
  : {}),
789
812
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
813
+ _handlerRan: handlerRan,
790
814
  };
791
815
 
792
816
  return { segment, matchedId };
@@ -867,7 +891,6 @@ export async function resolveSegmentWithRevalidation<TEnv>(
867
891
  prevUrl,
868
892
  nextUrl,
869
893
  routeKey,
870
- loaderPromises,
871
894
  true,
872
895
  deps,
873
896
  actionContext,
@@ -952,7 +975,6 @@ export async function resolveSegmentWithRevalidation<TEnv>(
952
975
  prevUrl,
953
976
  nextUrl,
954
977
  routeKey,
955
- loaderPromises,
956
978
  false,
957
979
  deps,
958
980
  actionContext,
@@ -979,7 +1001,6 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
979
1001
  prevUrl: URL,
980
1002
  nextUrl: URL,
981
1003
  routeKey: string,
982
- loaderPromises: Map<string, Promise<any>>,
983
1004
  belongsToRoute: boolean,
984
1005
  deps: SegmentResolutionDeps<TEnv>,
985
1006
  actionContext?: ActionContext,
@@ -1036,6 +1057,10 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1036
1057
  orphan.shortCode,
1037
1058
  stale,
1038
1059
  );
1060
+ // Tag as inherited so buildMatchResult can deduplicate when safe
1061
+ for (const s of inheritedResult.segments) {
1062
+ s._inherited = true;
1063
+ }
1039
1064
  segments.push(...inheritedResult.segments);
1040
1065
  matchedIds.push(...inheritedResult.matchedIds);
1041
1066
  }
@@ -1126,6 +1151,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1126
1151
  );
1127
1152
 
1128
1153
  if (!resolvedParallelEntries.has(parallelEntry.id)) {
1154
+ // shortCodeOverride must match the parent layout, not the parallel entry.
1129
1155
  const loaderResult = await resolveLoadersWithRevalidation(
1130
1156
  parallelEntry,
1131
1157
  context,
@@ -1138,7 +1164,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1138
1164
  routeKey,
1139
1165
  deps,
1140
1166
  actionContext,
1141
- undefined,
1167
+ orphan.shortCode,
1142
1168
  stale,
1143
1169
  );
1144
1170
  segments.push(...loaderResult.segments);
@@ -1160,21 +1186,20 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1160
1186
  const parallelId = `${orphan.shortCode}.${slot}`;
1161
1187
  matchedIds.push(parallelId);
1162
1188
 
1163
- const shouldResolve = await (async () => {
1164
- if (!clientSegmentIds.has(parallelId)) {
1165
- if (isTraceActive()) {
1166
- pushRevalidationTraceEntry({
1167
- segmentId: parallelId,
1168
- segmentType: "parallel",
1169
- belongsToRoute,
1170
- source: "parallel",
1171
- defaultShouldRevalidate: true,
1172
- finalShouldRevalidate: true,
1173
- reason: "new-segment",
1174
- });
1175
- }
1176
- return true;
1177
- }
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" };
1178
1203
 
1179
1204
  const dummySegment: ResolvedSegment = {
1180
1205
  id: parallelId,
@@ -1191,7 +1216,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1191
1216
  : {}),
1192
1217
  };
1193
1218
 
1194
- return await evaluateRevalidation({
1219
+ shouldResolve = await evaluateRevalidation({
1195
1220
  segment: dummySegment,
1196
1221
  prevParams,
1197
1222
  getPrevSegment: null,
@@ -1207,8 +1232,9 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1207
1232
  actionContext,
1208
1233
  stale,
1209
1234
  traceSource: "parallel",
1235
+ defaultOverride,
1210
1236
  });
1211
- })();
1237
+ }
1212
1238
  emitRevalidationDecision(
1213
1239
  parallelId,
1214
1240
  context.pathname,
@@ -1217,6 +1243,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1217
1243
  );
1218
1244
 
1219
1245
  let component: ReactNode | undefined;
1246
+ let handlerRan = false;
1220
1247
  if (shouldResolve) {
1221
1248
  component = await tryStaticSlot(parallelEntry, slot, parallelId);
1222
1249
  }
@@ -1228,29 +1255,35 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1228
1255
  } else if (handler === undefined) {
1229
1256
  // Handler evicted (production static slot) but static lookup missed.
1230
1257
  component = null;
1231
- } else if (hasLoadingFallback) {
1232
- const result =
1233
- typeof handler === "function" ? handler(context) : handler;
1234
- if (result instanceof Promise) {
1235
- const tracked = deps.trackHandler(result, {
1236
- segmentId: parallelId,
1237
- segmentType: "parallel",
1238
- });
1239
- observeStreamedHandler(
1240
- tracked,
1241
- parallelId,
1242
- "parallel",
1243
- context.pathname,
1244
- routeKey,
1245
- params,
1246
- );
1247
- 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
+ }
1248
1283
  } else {
1249
- component = result as ReactNode;
1284
+ component =
1285
+ typeof handler === "function" ? await handler(context) : handler;
1250
1286
  }
1251
- } else {
1252
- component =
1253
- typeof handler === "function" ? await handler(context) : handler;
1254
1287
  }
1255
1288
  }
1256
1289
 
@@ -1264,6 +1297,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1264
1297
  transition: parallelEntry.transition,
1265
1298
  params,
1266
1299
  slot,
1300
+ _handlerRan: handlerRan,
1267
1301
  belongsToRoute,
1268
1302
  parallelName: `${parallelEntry.id}.${slot}`,
1269
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
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { TrieNode, TrieLeaf } from "../build/route-trie.js";
9
+ import { safeDecodeURIComponent } from "./url-params.js";
9
10
 
10
11
  export interface TrieMatchResult {
11
12
  /** Route name */
@@ -14,7 +15,9 @@ export interface TrieMatchResult {
14
15
  sp: string;
15
16
  /** Matched route params */
16
17
  params: Record<string, string>;
17
- /** Optional param names (absent params have empty string value) */
18
+ /** Optional param names declared on the route. Absent params are omitted
19
+ * from `params` (read as `undefined`), matching the
20
+ * `ExtractParams<"/:locale?/...">` type. */
18
21
  optionalParams?: string[];
19
22
  /** Ancestry shortCodes for layout pruning */
20
23
  ancestry: string[];
@@ -173,20 +176,25 @@ function validateAndBuild(
173
176
  originalPathname: string,
174
177
  pathnameHasTrailingSlash: boolean,
175
178
  ): TrieMatchResult | null {
176
- // Build named params by zipping leaf.pa with positional paramValues
179
+ // Build named params by zipping leaf.pa with positional paramValues.
180
+ // Params are URL-decoded at this boundary so ctx.params holds the values
181
+ // apps expect (matching Express/React Router) and round-trip cleanly
182
+ // through ctx.reverse.
177
183
  const params: Record<string, string> = {};
178
184
  if (leaf.pa) {
179
185
  for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
180
- params[leaf.pa[i]] = paramValues[i];
186
+ params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
181
187
  }
182
188
  }
183
189
 
184
190
  // Add wildcard param (wildcard leaves have pn from TrieNode.w type)
185
191
  if (wildcardValue !== undefined && "pn" in leaf) {
186
- params[(leaf as TrieLeaf & { pn: string }).pn] = wildcardValue;
192
+ params[(leaf as TrieLeaf & { pn: string }).pn] =
193
+ safeDecodeURIComponent(wildcardValue);
187
194
  }
188
195
 
189
- // Validate constraints
196
+ // Validate constraints against decoded values so constraint lists can be
197
+ // written in decoded form (e.g. ["en-GB", "en US"]).
190
198
  if (leaf.cv) {
191
199
  for (const paramName in leaf.cv) {
192
200
  const allowed = leaf.cv[paramName]!;
@@ -197,14 +205,11 @@ function validateAndBuild(
197
205
  }
198
206
  }
199
207
 
200
- // Fill in empty strings for optional params that weren't matched
201
- if (leaf.op) {
202
- for (const name of leaf.op) {
203
- if (!(name in params)) {
204
- params[name] = "";
205
- }
206
- }
207
- }
208
+ // Optional params that weren't matched are left absent from `params` so
209
+ // `ctx.params.locale` reads as `undefined`, matching the
210
+ // `ExtractParams<"/:locale?/...">` type (`{ locale?: string }`). Both
211
+ // internal consumers — the constraint check above and `reverse()`
212
+ // already treat missing/undefined as the absent form.
208
213
 
209
214
  // Trailing slash handling
210
215
  const tsMode = leaf.ts as "never" | "always" | "ignore" | undefined;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * URL param encode/decode at the route boundary.
3
+ *
4
+ * Extraction (decode): regex/trie matchers keep param values URL-encoded;
5
+ * `safeDecodeURIComponent` turns them back into raw strings so `ctx.params`
6
+ * matches the contract apps expect (Express/React Router/Fastify/Koa) and
7
+ * round-trips through reverse stay stable. Malformed %-encoding is
8
+ * preserved as-is so a broken URL doesn't crash matching.
9
+ *
10
+ * Reversal (encode): `encodePathSegment` escapes only what RFC 3986
11
+ * requires for a path segment — `/`, `?`, `#`, space, control chars,
12
+ * non-ASCII — and leaves pchar sub-delims (`@ : $ & + , ; =` and friends)
13
+ * readable. `encodeURIComponent` over-encodes for path segments, which
14
+ * makes generated URLs harder for humans to read in the address bar
15
+ * (e.g. mailbox IDs like `ivo@example.com` would become
16
+ * `ivo%40example.com` even though `@` is path-legal).
17
+ */
18
+
19
+ export function safeDecodeURIComponent(raw: string): string {
20
+ if (raw === "" || raw.indexOf("%") === -1) return raw;
21
+ try {
22
+ return decodeURIComponent(raw);
23
+ } catch {
24
+ return raw;
25
+ }
26
+ }
27
+
28
+ // encodeURIComponent over-encodes for path segments. After running it,
29
+ // un-encode the pchar sub-delims + (`:` / `@`) so the resulting URL
30
+ // keeps human-readable characters that are legal in a path segment.
31
+ // Everything dangerous — `/ ? # %` and space/control/non-ASCII — stays
32
+ // encoded.
33
+ const PATH_SAFE_ESCAPES: Record<string, string> = {
34
+ "%3A": ":",
35
+ "%40": "@",
36
+ "%24": "$",
37
+ "%26": "&",
38
+ "%2B": "+",
39
+ "%2C": ",",
40
+ "%3B": ";",
41
+ "%3D": "=",
42
+ };
43
+
44
+ export function encodePathSegment(value: string): string {
45
+ return encodeURIComponent(value).replace(
46
+ /%(?:3A|40|24|26|2B|2C|3B|3D)/gi,
47
+ (match) => PATH_SAFE_ESCAPES[match.toUpperCase()] ?? match,
48
+ );
49
+ }
package/src/router.ts CHANGED
@@ -22,8 +22,7 @@ import type { UrlPatterns } from "./urls.js";
22
22
  import type { UrlBuilder } from "./urls/pattern-types.js";
23
23
  import { urls } from "./urls.js";
24
24
  import {
25
- EntryData,
26
- InterceptSelectorContext,
25
+ type EntryData,
27
26
  getContext,
28
27
  RSCRouterContext,
29
28
  type MetricsStore,