@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.
- package/README.md +112 -17
- package/dist/vite/index.js +1197 -454
- package/package.json +4 -2
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/SKILL.md +30 -2
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +25 -0
- package/skills/layout/SKILL.md +2 -0
- package/skills/links/SKILL.md +234 -16
- package/skills/loader/SKILL.md +70 -3
- package/skills/middleware/SKILL.md +2 -0
- package/skills/migrate-nextjs/SKILL.md +3 -1
- package/skills/migrate-react-router/SKILL.md +4 -0
- package/skills/parallel/SKILL.md +9 -0
- package/skills/rango/SKILL.md +2 -0
- 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 +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 +113 -6
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +44 -10
- package/src/browser/prefetch/cache.ts +16 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/NavigationProvider.tsx +64 -16
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/use-params.ts +8 -5
- 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/types.ts +19 -0
- package/src/build/route-trie.ts +2 -1
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +5 -1
- package/src/href-client.ts +4 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +62 -39
- package/src/route-definition/dsl-helpers.ts +16 -3
- package/src/route-definition/helpers-types.ts +6 -1
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/router/handler-context.ts +21 -41
- package/src/router/lazy-includes.ts +1 -1
- package/src/router/loader-resolution.ts +3 -0
- 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 +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 +8 -0
- package/src/router/segment-resolution/revalidation.ts +128 -100
- 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 +8 -4
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +11 -10
- package/src/rsc/rsc-rendering.ts +3 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +6 -0
- package/src/segment-system.tsx +60 -9
- 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/segments.ts +17 -0
- 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/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 +498 -52
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- 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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
251
|
-
*
|
|
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 {
|
|
396
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
469
|
-
|
|
470
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
@@ -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
|
+
}
|