@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.b30bbf02
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 +1338 -462
- 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 +362 -0
- package/skills/hooks/SKILL.md +33 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +90 -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 +66 -0
- package/skills/rango/SKILL.md +25 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +71 -5
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +34 -3
- 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/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +11 -1
- 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/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +19 -0
- package/src/build/route-trie.ts +50 -24
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.tsx +82 -174
- 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 +7 -3
- 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 +24 -4
- 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-result.ts +21 -2
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +87 -17
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +8 -0
- package/src/router/segment-resolution/revalidation.ts +128 -100
- 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 +8 -4
- 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 +11 -61
- 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 +17 -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/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 +48 -1
- 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-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +516 -486
- 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 +20 -6
|
@@ -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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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 =
|
|
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
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
-
|
|
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
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
tracked,
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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 =
|
|
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
|
|
@@ -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,
|
package/src/rsc/handler.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
interceptRedirectForPartial,
|
|
32
32
|
buildRouteMiddlewareEntries,
|
|
33
33
|
} from "./helpers.js";
|
|
34
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
34
35
|
import {
|
|
35
36
|
handleResponseRoute,
|
|
36
37
|
type ResponseRouteMatch,
|
|
@@ -56,6 +57,7 @@ import {
|
|
|
56
57
|
getRouterTrie,
|
|
57
58
|
} from "../route-map-builder.js";
|
|
58
59
|
import type { HandlerContext } from "./handler-context.js";
|
|
60
|
+
import type { SegmentCacheStore } from "../cache/types.js";
|
|
59
61
|
import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
|
|
60
62
|
import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
|
|
61
63
|
import {
|
|
@@ -352,7 +354,7 @@ export function createRSCHandler<
|
|
|
352
354
|
// Resolve cache store configuration
|
|
353
355
|
// Priority: options.cache (handler override) > router.cache (router default)
|
|
354
356
|
// Store is enabled only if: config provided, enabled, and no ?__no_cache query param
|
|
355
|
-
let cacheStore
|
|
357
|
+
let cacheStore: SegmentCacheStore | undefined;
|
|
356
358
|
const cacheOption = options.cache ?? router.cache;
|
|
357
359
|
if (cacheOption && !url.searchParams.has("__no_cache")) {
|
|
358
360
|
const cacheConfig =
|
|
@@ -533,7 +535,9 @@ export function createRSCHandler<
|
|
|
533
535
|
}
|
|
534
536
|
|
|
535
537
|
const fullTiming = timingParts.join(", ");
|
|
536
|
-
if (fullTiming
|
|
538
|
+
if (fullTiming && !isWebSocketUpgradeResponse(response)) {
|
|
539
|
+
response.headers.set("Server-Timing", fullTiming);
|
|
540
|
+
}
|
|
537
541
|
|
|
538
542
|
return response;
|
|
539
543
|
});
|
|
@@ -804,7 +808,7 @@ export function createRSCHandler<
|
|
|
804
808
|
);
|
|
805
809
|
}
|
|
806
810
|
const response = responseOutcome.result;
|
|
807
|
-
if (plan.negotiated) {
|
|
811
|
+
if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
808
812
|
response.headers.append("Vary", "Accept");
|
|
809
813
|
}
|
|
810
814
|
return response;
|
|
@@ -1014,7 +1018,7 @@ export function createRSCHandler<
|
|
|
1014
1018
|
nonce,
|
|
1015
1019
|
);
|
|
1016
1020
|
}
|
|
1017
|
-
if (negotiated) {
|
|
1021
|
+
if (negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
1018
1022
|
response.headers.append("Vary", "Accept");
|
|
1019
1023
|
}
|
|
1020
1024
|
return response;
|