@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.
- package/README.md +112 -17
- package/dist/vite/index.js +1456 -467
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +54 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +45 -0
- package/skills/layout/SKILL.md +24 -0
- package/skills/links/SKILL.md +234 -16
- package/skills/loader/SKILL.md +70 -3
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/parallel/SKILL.md +68 -0
- package/skills/rango/SKILL.md +26 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +48 -0
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +9 -1
- package/skills/view-transitions/SKILL.md +212 -0
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +80 -5
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +58 -12
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +70 -18
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +17 -4
- package/src/browser/react/use-reverse.ts +99 -0
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +19 -0
- package/src/build/route-trie.ts +52 -25
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +87 -175
- package/src/href-client.ts +4 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +40 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +62 -36
- package/src/route-definition/dsl-helpers.ts +175 -23
- package/src/route-definition/helpers-types.ts +63 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +21 -38
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/cache-lookup.ts +2 -1
- package/src/router/match-result.ts +101 -4
- package/src/router/middleware-types.ts +14 -25
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +13 -0
- package/src/router/segment-resolution/revalidation.ts +135 -101
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +16 -8
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +10 -0
- package/src/rsc/server-action.ts +4 -0
- package/src/rsc/types.ts +6 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +71 -70
- package/src/server/context.ts +26 -3
- package/src/server/request-context.ts +10 -42
- package/src/ssr/index.tsx +5 -1
- package/src/types/handler-context.ts +12 -39
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +18 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/urls/response-types.ts +2 -10
- package/src/use-loader.tsx +4 -1
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +172 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +540 -376
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +558 -53
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- 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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
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
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
tracked,
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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 =
|
|
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
|
|
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] =
|
|
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
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,
|