@rangojs/router 0.0.0-experimental.139 → 0.0.0-experimental.140
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/dist/bin/rango.js +27 -2
- package/dist/vite/index.js +147 -30
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/cache-guide/SKILL.md +1 -0
- package/skills/caching/SKILL.md +1 -1
- package/skills/migrate-nextjs/SKILL.md +15 -0
- package/skills/migrate-react-router/SKILL.md +15 -2
- package/skills/ppr/SKILL.md +426 -0
- package/skills/rango/SKILL.md +28 -25
- package/skills/route/SKILL.md +43 -0
- package/src/build/route-trie.ts +35 -7
- package/src/cache/cf/cf-cache-store.ts +155 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory-segment-store.ts +57 -1
- package/src/cache/shell-cache.ts +386 -0
- package/src/cache/types.ts +58 -0
- package/src/cache/vercel/vercel-cache-store.ts +159 -5
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +17 -0
- package/src/router/middleware.ts +14 -5
- package/src/router/parse-pattern.ts +115 -0
- package/src/router/pattern-matching.ts +53 -64
- package/src/router/segment-resolution/fresh.ts +12 -1
- package/src/router/segment-resolution/loader-cache.ts +14 -0
- package/src/router/segment-resolution/loader-mask.ts +44 -0
- package/src/router/substitute-pattern-params.ts +54 -35
- package/src/router/trie-matching.ts +19 -11
- package/src/router/url-params.ts +13 -0
- package/src/rsc/full-payload.ts +70 -0
- package/src/rsc/rsc-rendering.ts +105 -51
- package/src/rsc/shell-capture.ts +439 -0
- package/src/rsc/types.ts +26 -0
- package/src/server/cookie-store.ts +45 -0
- package/src/server/live.ts +130 -0
- package/src/server/request-context.ts +49 -0
- package/src/ssr/index.tsx +377 -180
- package/src/ssr/ssr-root.tsx +228 -0
- package/src/testing/render-route.tsx +7 -9
- package/src/types/route-config.ts +19 -7
- package/src/urls/type-extraction.ts +43 -18
- package/src/vite/discovery/discovery-errors.ts +61 -0
- package/src/vite/plugins/virtual-entries.ts +27 -2
- package/src/vite/router-discovery.ts +69 -15
- package/src/vite/utils/prerender-utils.ts +17 -4
package/src/index.ts
CHANGED
|
@@ -228,6 +228,23 @@ export function headers(): never {
|
|
|
228
228
|
throw serverOnlyStubError("headers");
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Client/SSR passthrough for `live()` (the PPR hole primitive). Unlike the
|
|
233
|
+
* cookies()/headers() stubs this is a REAL function: there is no shell capture
|
|
234
|
+
* off the react-server condition, so live() simply runs the thunk (or returns
|
|
235
|
+
* the promise). The capture-aware implementation lives in index.rsc.ts
|
|
236
|
+
* (./server/live.js). See docs/design/ppr-shell-resume.md.
|
|
237
|
+
*/
|
|
238
|
+
export function live<T>(fn: () => Promise<T> | T): Promise<T>;
|
|
239
|
+
export function live<T>(promise: Promise<T>): Promise<T>;
|
|
240
|
+
export function live<T>(
|
|
241
|
+
input: (() => Promise<T> | T) | Promise<T>,
|
|
242
|
+
): Promise<T> {
|
|
243
|
+
return typeof input === "function"
|
|
244
|
+
? Promise.resolve((input as () => Promise<T> | T)())
|
|
245
|
+
: input;
|
|
246
|
+
}
|
|
247
|
+
|
|
231
248
|
/**
|
|
232
249
|
* Client implementation of `invalidateClientCache()`. Unlike the server-only
|
|
233
250
|
* stubs above this is a REAL function under the `default` condition (it marks
|
package/src/router/middleware.ts
CHANGED
|
@@ -104,11 +104,20 @@ export function compileMiddlewarePattern(pattern: string): {
|
|
|
104
104
|
const segment = segments[i];
|
|
105
105
|
|
|
106
106
|
if (segment.type === "wildcard") {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
if (segment.value === "*") {
|
|
108
|
+
// Bare `*`: optional subtree match, no capture (parity with the original
|
|
109
|
+
// middleware parser). A trailing `*` matches the subtree; a non-trailing
|
|
110
|
+
// `*` matches zero-or-more intermediate segments, so `/a/<star>/b` still
|
|
111
|
+
// matches `/a/b`.
|
|
112
|
+
regexStr += "(?:/.*)?";
|
|
113
|
+
} else {
|
|
114
|
+
// Named catch-all `:name+` / `:name*`: capture the remainder under the
|
|
115
|
+
// name so a scoping middleware sees `ctx.params.<name>`, and respect the
|
|
116
|
+
// one-or-more arity (`+` must not match the bare prefix), mirroring the
|
|
117
|
+
// route matcher instead of collapsing to the bare-`*` subtree.
|
|
118
|
+
paramNames.push(segment.value);
|
|
119
|
+
regexStr += segment.oneOrMore ? "/(.+)" : "(?:/(.*))?";
|
|
120
|
+
}
|
|
112
121
|
if (i === segments.length - 1) {
|
|
113
122
|
hasTrailingWildcard = true;
|
|
114
123
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route pattern parsing (grammar only).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately dependency-free so it is safe to bundle into the CLIENT — the
|
|
5
|
+
* reverse helper (`substitute-pattern-params.ts` -> `use-reverse`) needs it, and
|
|
6
|
+
* pulling it from `pattern-matching.ts` would drag that module's server-only
|
|
7
|
+
* transitive imports (`node:async_hooks` via `logging.ts`) into the browser.
|
|
8
|
+
* `pattern-matching.ts` re-exports these so existing importers are unaffected.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parsed segment info
|
|
13
|
+
*/
|
|
14
|
+
export interface ParsedSegment {
|
|
15
|
+
type: "static" | "param" | "wildcard";
|
|
16
|
+
value: string; // static text, param name, or "*"
|
|
17
|
+
optional: boolean;
|
|
18
|
+
constraint?: string[]; // enum values like ["en", "gb"]
|
|
19
|
+
suffix?: string; // literal text after param in same segment (e.g., ".html")
|
|
20
|
+
/**
|
|
21
|
+
* Named catch-all repeat modifier. On a `wildcard` segment whose `value` is a
|
|
22
|
+
* param name (`:name+` / `:name*`), `true` marks one-or-more (`+`, rejects the
|
|
23
|
+
* zero-segment case); absent/false marks zero-or-more (`*`, and the bare `/*`).
|
|
24
|
+
*/
|
|
25
|
+
oneOrMore?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a route pattern into segments
|
|
30
|
+
*
|
|
31
|
+
* Supports:
|
|
32
|
+
* - Static: /blog, /about
|
|
33
|
+
* - Params: /:slug, /:id
|
|
34
|
+
* - Optional: /:locale?, /:page?
|
|
35
|
+
* - Constrained: /:locale(en|gb), /:type(post|page)
|
|
36
|
+
* - Optional + Constrained: /:locale(en|gb)?
|
|
37
|
+
* - Wildcard: /*
|
|
38
|
+
* - Named catch-all: /:slug* (zero-or-more), /:path+ (one-or-more)
|
|
39
|
+
*/
|
|
40
|
+
export function parsePattern(pattern: string): ParsedSegment[] {
|
|
41
|
+
const segments: ParsedSegment[] = [];
|
|
42
|
+
// The `([+*])?` group peels a trailing `+`/`*` off a `:name` BEFORE the
|
|
43
|
+
// literal-suffix group `([^/]*)` so it can be inspected. Whether it is a
|
|
44
|
+
// catch-all MODIFIER or a literal suffix character is decided below — a bare
|
|
45
|
+
// trailing `+`/`*` is the named catch-all of issue #634; any other combination
|
|
46
|
+
// is folded back into the literal suffix so previously-valid patterns are
|
|
47
|
+
// unaffected. It sits after `(\?)?` so `:name?*` is seen as `?` + suffix `*`.
|
|
48
|
+
const segmentRegex =
|
|
49
|
+
/\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([+*])?([^/]*)|(\*)|([^/]+))/g;
|
|
50
|
+
|
|
51
|
+
let match;
|
|
52
|
+
while ((match = segmentRegex.exec(pattern)) !== null) {
|
|
53
|
+
const [
|
|
54
|
+
,
|
|
55
|
+
,
|
|
56
|
+
paramName,
|
|
57
|
+
,
|
|
58
|
+
constraint,
|
|
59
|
+
optional,
|
|
60
|
+
repeat,
|
|
61
|
+
suffix,
|
|
62
|
+
wildcard,
|
|
63
|
+
staticText,
|
|
64
|
+
] = match;
|
|
65
|
+
|
|
66
|
+
if (wildcard) {
|
|
67
|
+
// Bare `/*`: zero-or-more, captured under "*".
|
|
68
|
+
segments.push({ type: "wildcard", value: "*", optional: false });
|
|
69
|
+
} else if (paramName) {
|
|
70
|
+
// A trailing `+`/`*` is a named catch-all ONLY when it stands alone on the
|
|
71
|
+
// param — no `?`, no constraint, no literal suffix after it. In any other
|
|
72
|
+
// combination it is the start of a literal suffix, exactly as before this
|
|
73
|
+
// feature existed, so `:version+build` still matches `/…/v1+build` and
|
|
74
|
+
// never throws at registration.
|
|
75
|
+
if (repeat && !suffix && optional !== "?" && !constraint) {
|
|
76
|
+
segments.push({
|
|
77
|
+
type: "wildcard",
|
|
78
|
+
value: paramName,
|
|
79
|
+
optional: false,
|
|
80
|
+
oneOrMore: repeat === "+",
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
segments.push({
|
|
84
|
+
type: "param",
|
|
85
|
+
value: paramName,
|
|
86
|
+
optional: optional === "?",
|
|
87
|
+
constraint: constraint ? constraint.split("|") : undefined,
|
|
88
|
+
// Fold a non-modifier `+`/`*` back into the literal suffix.
|
|
89
|
+
suffix: (repeat ?? "") + (suffix ?? "") || undefined,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
} else if (staticText) {
|
|
93
|
+
segments.push({ type: "static", value: staticText, optional: false });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A named catch-all consumes the remainder, so it only makes sense as the final
|
|
98
|
+
// segment. If it isn't last, it isn't really a catch-all: restore the literal
|
|
99
|
+
// parse (`:name` + literal `+`/`*` suffix) rather than error, so a pattern like
|
|
100
|
+
// `/docs/:slug+/edit` keeps its pre-feature behavior (matches `/docs/x+/edit`).
|
|
101
|
+
// Bare `/*` keeps its historical mid-pattern leniency and is left untouched.
|
|
102
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
103
|
+
const s = segments[i];
|
|
104
|
+
if (s.type === "wildcard" && s.value !== "*") {
|
|
105
|
+
segments[i] = {
|
|
106
|
+
type: "param",
|
|
107
|
+
value: s.value,
|
|
108
|
+
optional: false,
|
|
109
|
+
suffix: s.oneOrMore ? "+" : "*",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return segments;
|
|
115
|
+
}
|
|
@@ -9,65 +9,13 @@ import type { EntryData } from "../server/context";
|
|
|
9
9
|
import { debugLog, isRouterDebugEnabled } from "./logging.js";
|
|
10
10
|
import { escapeRegExp } from "../regex-escape.js";
|
|
11
11
|
import { safeDecodeURIComponent } from "./url-params.js";
|
|
12
|
+
import { parsePattern, type ParsedSegment } from "./parse-pattern.js";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
value: string; // static text, param name, or "*"
|
|
19
|
-
optional: boolean;
|
|
20
|
-
constraint?: string[]; // enum values like ["en", "gb"]
|
|
21
|
-
suffix?: string; // literal text after param in same segment (e.g., ".html")
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Parse a route pattern into segments
|
|
26
|
-
*
|
|
27
|
-
* Supports:
|
|
28
|
-
* - Static: /blog, /about
|
|
29
|
-
* - Params: /:slug, /:id
|
|
30
|
-
* - Optional: /:locale?, /:page?
|
|
31
|
-
* - Constrained: /:locale(en|gb), /:type(post|page)
|
|
32
|
-
* - Optional + Constrained: /:locale(en|gb)?
|
|
33
|
-
* - Wildcard: /*
|
|
34
|
-
*/
|
|
35
|
-
export function parsePattern(pattern: string): ParsedSegment[] {
|
|
36
|
-
const segments: ParsedSegment[] = [];
|
|
37
|
-
const segmentRegex =
|
|
38
|
-
/\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
|
|
39
|
-
|
|
40
|
-
let match;
|
|
41
|
-
while ((match = segmentRegex.exec(pattern)) !== null) {
|
|
42
|
-
const [
|
|
43
|
-
,
|
|
44
|
-
,
|
|
45
|
-
paramName,
|
|
46
|
-
,
|
|
47
|
-
constraint,
|
|
48
|
-
optional,
|
|
49
|
-
suffix,
|
|
50
|
-
wildcard,
|
|
51
|
-
staticText,
|
|
52
|
-
] = match;
|
|
53
|
-
|
|
54
|
-
if (wildcard) {
|
|
55
|
-
segments.push({ type: "wildcard", value: "*", optional: false });
|
|
56
|
-
} else if (paramName) {
|
|
57
|
-
segments.push({
|
|
58
|
-
type: "param",
|
|
59
|
-
value: paramName,
|
|
60
|
-
optional: optional === "?",
|
|
61
|
-
constraint: constraint ? constraint.split("|") : undefined,
|
|
62
|
-
suffix: suffix || undefined,
|
|
63
|
-
});
|
|
64
|
-
} else if (staticText) {
|
|
65
|
-
segments.push({ type: "static", value: staticText, optional: false });
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return segments;
|
|
70
|
-
}
|
|
14
|
+
// `parsePattern`/`ParsedSegment` live in the dependency-free `parse-pattern.ts`
|
|
15
|
+
// so the client reverse helper can import them without dragging this module's
|
|
16
|
+
// server-only deps into the browser bundle. Re-exported here for existing
|
|
17
|
+
// importers (build/route-trie, middleware, tests).
|
|
18
|
+
export { parsePattern, type ParsedSegment };
|
|
71
19
|
|
|
72
20
|
/**
|
|
73
21
|
* Compiled pattern result containing regex, param metadata, and trailing slash info.
|
|
@@ -83,6 +31,14 @@ export interface CompiledPattern {
|
|
|
83
31
|
* path's behavior (trie-matching.ts:validateAndBuild).
|
|
84
32
|
*/
|
|
85
33
|
constraints?: Record<string, string[]>;
|
|
34
|
+
/**
|
|
35
|
+
* The pattern's catch-all param, if any (`*` for bare `/*`, the name for a
|
|
36
|
+
* named `:name+`/`:name*`). A zero-or-more catch-all (`oneOrMore: false`)
|
|
37
|
+
* whose optional group is absent binds "" rather than being omitted — so
|
|
38
|
+
* `/docs` matches `/docs/:slug*` with `slug === ""`. `oneOrMore` keeps the
|
|
39
|
+
* same polarity as `ParsedSegment.oneOrMore` and the trie's `w1`.
|
|
40
|
+
*/
|
|
41
|
+
catchAll?: { name: string; oneOrMore: boolean };
|
|
86
42
|
}
|
|
87
43
|
|
|
88
44
|
// Module-level cache for compiled patterns. Route patterns are a finite set
|
|
@@ -143,13 +99,32 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
143
99
|
const segments = parsePattern(normalizedPattern);
|
|
144
100
|
const paramNames: string[] = [];
|
|
145
101
|
let constraints: Record<string, string[]> | undefined;
|
|
102
|
+
let catchAll: { name: string; oneOrMore: boolean } | undefined;
|
|
146
103
|
|
|
147
104
|
let regexPattern = "";
|
|
148
105
|
|
|
149
106
|
for (const segment of segments) {
|
|
150
107
|
if (segment.type === "wildcard") {
|
|
151
|
-
|
|
152
|
-
|
|
108
|
+
// Wildcards capture the remainder under `segment.value` ("*" for the bare
|
|
109
|
+
// form, the param name for a named catch-all).
|
|
110
|
+
paramNames.push(segment.value);
|
|
111
|
+
catchAll = { name: segment.value, oneOrMore: Boolean(segment.oneOrMore) };
|
|
112
|
+
if (segment.oneOrMore) {
|
|
113
|
+
// `:name+` — one-or-more, rejects the zero-segment (bare-prefix) case.
|
|
114
|
+
regexPattern += "/(.+)";
|
|
115
|
+
} else {
|
|
116
|
+
// Zero-or-more catch-all: named `:name*` OR the bare `/*` (both parse to
|
|
117
|
+
// `oneOrMore: false`). The whole `/segment` is optional so the bare
|
|
118
|
+
// prefix matches directly, aligning the regex fallback with the trie
|
|
119
|
+
// (which already matches the bare prefix binding "" — trie-matching.ts);
|
|
120
|
+
// buildParamsFromMatch binds "" when the optional group is absent.
|
|
121
|
+
//
|
|
122
|
+
// The bare `/*` previously used a required `/(.*)`, so `/files/*` failed
|
|
123
|
+
// to match `/files` and fell through to trailing-slash normalization,
|
|
124
|
+
// emitting a corrupt `/file` redirect instead of a match (issue #636,
|
|
125
|
+
// parity row C1). It is the same alignment #635 made for named `:name*`.
|
|
126
|
+
regexPattern += "(?:/(.*))?";
|
|
127
|
+
}
|
|
153
128
|
} else if (segment.type === "param") {
|
|
154
129
|
paramNames.push(segment.value);
|
|
155
130
|
const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
|
|
@@ -202,6 +177,7 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
202
177
|
paramNames,
|
|
203
178
|
hasTrailingSlash,
|
|
204
179
|
...(constraints ? { constraints } : {}),
|
|
180
|
+
...(catchAll ? { catchAll } : {}),
|
|
205
181
|
};
|
|
206
182
|
}
|
|
207
183
|
|
|
@@ -236,16 +212,29 @@ function satisfiesConstraints(
|
|
|
236
212
|
* keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
|
|
237
213
|
* keeps the runtime aligned with the `ExtractParams` type and matches the
|
|
238
214
|
* trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
|
|
215
|
+
*
|
|
216
|
+
* A zero-or-more catch-all (`compiled.catchAll`, `oneOrMore: false`) whose
|
|
217
|
+
* optional group didn't capture binds "" instead of being omitted, so `/docs`
|
|
218
|
+
* matches `/docs/:slug*` with `slug === ""`. Exported so the `renderRoute`
|
|
219
|
+
* testing harness (`matchLeaf`) shares this exact logic instead of forking it.
|
|
239
220
|
*/
|
|
240
|
-
function buildParamsFromMatch(
|
|
221
|
+
export function buildParamsFromMatch(
|
|
241
222
|
match: RegExpExecArray,
|
|
242
223
|
paramNames: string[],
|
|
224
|
+
catchAll?: { name: string; oneOrMore: boolean },
|
|
243
225
|
): Record<string, string> {
|
|
244
226
|
const params: Record<string, string> = {};
|
|
245
227
|
paramNames.forEach((name, index) => {
|
|
246
228
|
const captured = match[index + 1];
|
|
247
229
|
if (captured !== undefined) {
|
|
230
|
+
// A catch-all remainder decodes identically whether split-per-segment or
|
|
231
|
+
// whole-string (a literal `/` never lives inside a `%XX` escape), so a
|
|
232
|
+
// single decode is correct and cheapest.
|
|
248
233
|
params[name] = safeDecodeURIComponent(captured);
|
|
234
|
+
} else if (catchAll && name === catchAll.name && !catchAll.oneOrMore) {
|
|
235
|
+
// A zero-or-more catch-all (`:name*` or the bare `/*`) whose optional
|
|
236
|
+
// group was absent binds "" rather than being omitted.
|
|
237
|
+
params[name] = "";
|
|
249
238
|
}
|
|
250
239
|
});
|
|
251
240
|
return params;
|
|
@@ -454,7 +443,7 @@ export function findMatch<TEnv>(
|
|
|
454
443
|
fullPattern = entry.prefix + pattern;
|
|
455
444
|
}
|
|
456
445
|
|
|
457
|
-
const { regex, paramNames, hasTrailingSlash, constraints } =
|
|
446
|
+
const { regex, paramNames, hasTrailingSlash, constraints, catchAll } =
|
|
458
447
|
getCompiledPattern(fullPattern);
|
|
459
448
|
|
|
460
449
|
const trailingSlashMode: TrailingSlashMode | undefined =
|
|
@@ -469,7 +458,7 @@ export function findMatch<TEnv>(
|
|
|
469
458
|
|
|
470
459
|
const match = regex.exec(pathname);
|
|
471
460
|
if (match) {
|
|
472
|
-
const params = buildParamsFromMatch(match, paramNames);
|
|
461
|
+
const params = buildParamsFromMatch(match, paramNames, catchAll);
|
|
473
462
|
|
|
474
463
|
if (!satisfiesConstraints(params, constraints)) {
|
|
475
464
|
continue;
|
|
@@ -518,7 +507,7 @@ export function findMatch<TEnv>(
|
|
|
518
507
|
|
|
519
508
|
const altMatch = regex.exec(alternatePathname);
|
|
520
509
|
if (altMatch) {
|
|
521
|
-
const params = buildParamsFromMatch(altMatch, paramNames);
|
|
510
|
+
const params = buildParamsFromMatch(altMatch, paramNames, catchAll);
|
|
522
511
|
|
|
523
512
|
if (!satisfiesConstraints(params, constraints)) {
|
|
524
513
|
continue;
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
} from "../../types";
|
|
20
20
|
import type { SegmentResolutionDeps } from "../types.js";
|
|
21
21
|
import { resolveLoaderData } from "./loader-cache.js";
|
|
22
|
+
import { isShellCaptureActive } from "./loader-mask.js";
|
|
22
23
|
import {
|
|
23
24
|
handleHandlerResult,
|
|
24
25
|
tryStaticHandler,
|
|
@@ -60,6 +61,16 @@ export async function resolveLoaders<TEnv>(
|
|
|
60
61
|
const hasLoading = "loading" in entry && entry.loading !== undefined;
|
|
61
62
|
const loadingDisabled = hasLoading && entry.loading === false;
|
|
62
63
|
|
|
64
|
+
// Emit the streaming (non-awaiting) loader shape when loading is enabled OR
|
|
65
|
+
// during a PPR shell capture. In capture, loaders are masked with
|
|
66
|
+
// never-resolving promises (loader-mask.ts); the loading-disabled branch below
|
|
67
|
+
// AWAITS the loader promises, which would hang the capture render's match()
|
|
68
|
+
// forever on those masked promises. Forcing the streaming shape lets match()
|
|
69
|
+
// complete so the prerender can postpone the loader subtrees as holes. The
|
|
70
|
+
// `!loadingDisabled` short-circuit keeps the ALS check off the hot path (only
|
|
71
|
+
// loading-disabled entries consult it), so normal requests are unchanged.
|
|
72
|
+
const emitStreaming = !loadingDisabled || isShellCaptureActive();
|
|
73
|
+
|
|
63
74
|
// Error context for wrapLoaderPromise: without it, a throwing DSL loader never
|
|
64
75
|
// fires createRouter({ onError }) (phase "loader") nor emits the loader.error
|
|
65
76
|
// telemetry event — wrapLoaderPromise only builds the onError/telemetry path
|
|
@@ -67,7 +78,7 @@ export async function resolveLoaders<TEnv>(
|
|
|
67
78
|
// loader failures the same way handlers/actions/routing/fetchable-loaders do.
|
|
68
79
|
const errorContext = buildLoaderErrorContext(ctx);
|
|
69
80
|
|
|
70
|
-
if (
|
|
81
|
+
if (emitStreaming) {
|
|
71
82
|
// Streaming loaders: promises kick off now, settle during RSC serialization.
|
|
72
83
|
const segments = loaderEntries.map((loaderEntry, i) => {
|
|
73
84
|
const { loader } = loaderEntry;
|
|
@@ -36,6 +36,10 @@ import {
|
|
|
36
36
|
} from "../../cache/cache-policy.js";
|
|
37
37
|
import { readThroughItem } from "../../cache/read-through-swr.js";
|
|
38
38
|
import { recordRequestTags } from "../../cache/cache-tag.js";
|
|
39
|
+
import {
|
|
40
|
+
isShellCaptureActive,
|
|
41
|
+
createMaskedLoaderPromise,
|
|
42
|
+
} from "./loader-mask.js";
|
|
39
43
|
// Lazy-loaded to avoid pulling @vitejs/plugin-rsc/rsc into modules that
|
|
40
44
|
// import segment-resolution but never use loader caching.
|
|
41
45
|
let _serializeResult: typeof import("../../cache/segment-codec.js").serializeResult;
|
|
@@ -127,6 +131,16 @@ export function resolveLoaderData<TEnv>(
|
|
|
127
131
|
ctx: HandlerContext<any, TEnv>,
|
|
128
132
|
pathname: string,
|
|
129
133
|
): Promise<any> {
|
|
134
|
+
// PPR shell capture: never execute the loader. Its slot gets a never-resolving
|
|
135
|
+
// promise so the Suspense subtree postpones (a hole). Gate here — the single
|
|
136
|
+
// funnel every loader segment path routes through (fresh resolveLoaders,
|
|
137
|
+
// cache-hit resolveLoadersOnly, revalidation resolveLoadersOnlyWithRevalidation)
|
|
138
|
+
// — so no loader fn runs and no loader-cache getItem/setItem round-trip happens
|
|
139
|
+
// during capture. See loader-mask.ts and docs/design/ppr-shell-resume.md.
|
|
140
|
+
if (isShellCaptureActive()) {
|
|
141
|
+
return createMaskedLoaderPromise();
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
const cacheConfig = loaderEntry.cache;
|
|
131
145
|
|
|
132
146
|
// No cache config or disabled — run fresh (zero overhead path)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPR shell-capture loader masking.
|
|
3
|
+
*
|
|
4
|
+
* During a shell CAPTURE re-render (Axis 2, see docs/design/ppr-shell-resume.md)
|
|
5
|
+
* route loaders are the "live lane": they must NOT execute — no side effects, no
|
|
6
|
+
* cost, no cache round-trips. Instead every loader segment's value slot receives
|
|
7
|
+
* a never-resolving promise, so the loader-consuming Suspense subtree stays
|
|
8
|
+
* pending and React's static `prerender` marks it as a postponed hole. The frozen
|
|
9
|
+
* shell (prelude) captures only the fallback; the resumed serve pass runs the
|
|
10
|
+
* loaders fresh through the unchanged execution path and streams their output
|
|
11
|
+
* into the holes.
|
|
12
|
+
*
|
|
13
|
+
* Capture mode is signalled by `requestCtx._shellCaptureRun`, set to true ONLY on
|
|
14
|
+
* the derived request context of the background capture task (shell-capture.ts) —
|
|
15
|
+
* NOT by the foreground render, whose `_shellCapture` descriptor merely means "a
|
|
16
|
+
* capture is wanted" and must not change behavior. This module is the single home
|
|
17
|
+
* for the mask so every loader execution site gates the same way (loader-cache.ts
|
|
18
|
+
* `resolveLoaderData`, fresh.ts `resolveLoaders`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { _getRequestContext } from "../../server/request-context.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when the current render is the active PPR shell capture and route loaders
|
|
25
|
+
* must be masked rather than executed. Reads `_shellCaptureRun` off the ALS
|
|
26
|
+
* request context (the capture task re-establishes its derived context via
|
|
27
|
+
* runWithRequestContext), so it is accurate at the loader resolution sites, which
|
|
28
|
+
* run synchronously inside the pipeline's context frame.
|
|
29
|
+
*/
|
|
30
|
+
export function isShellCaptureActive(): boolean {
|
|
31
|
+
return _getRequestContext()?._shellCaptureRun === true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A promise that never settles — the masked stand-in for a loader's value during
|
|
36
|
+
* shell capture. The consuming Suspense subtree suspends forever, so the static
|
|
37
|
+
* prerender postpones it as a hole instead of baking a per-request value into the
|
|
38
|
+
* shared shell. The capture abort (`maxWaitMs` in captureShellHTML) bounds how
|
|
39
|
+
* long the prerender waits before it freezes the prelude, so this never hangs the
|
|
40
|
+
* request.
|
|
41
|
+
*/
|
|
42
|
+
export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
|
|
43
|
+
return new Promise<T>(() => {});
|
|
44
|
+
}
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import { encodePathSegment } from "./url-params.js";
|
|
1
|
+
import { encodePathSegment, encodePathRemainder } from "./url-params.js";
|
|
2
|
+
import { parsePattern } from "./parse-pattern.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Substitute `:param` placeholders in a route pattern with values from
|
|
5
|
-
* `params
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `params`, producing a URL. Built by walking the SAME parsed segments the
|
|
7
|
+
* matcher uses (`parsePattern`) and emitting one piece per segment — so a
|
|
8
|
+
* substituted value is never re-scanned as if it were another placeholder (a
|
|
9
|
+
* catch-all value like `sha:abc/x` used to make the "required" pass read `:abc`
|
|
10
|
+
* and throw). Constraint syntax (`:name(en|gb)`) is stripped; trailing-slash
|
|
11
|
+
* patterns like `/blog/` are preserved unless an optional segment was omitted.
|
|
12
|
+
*
|
|
13
|
+
* Semantics per segment:
|
|
14
|
+
* - static -> emitted verbatim.
|
|
15
|
+
* - `:name` -> required; `undefined` throws, `""` yields an empty segment.
|
|
16
|
+
* - `:name?` -> optional; `undefined`/`""` omitted.
|
|
17
|
+
* - `:name*` / `:name+`-> catch-all; the value is multi-segment, so each segment
|
|
18
|
+
* is encoded and the `/` separators are preserved. `+`
|
|
19
|
+
* (one-or-more) throws when absent; `*` (and bare `*`)
|
|
20
|
+
* omit when absent.
|
|
10
21
|
*
|
|
11
22
|
* Shared by `ctx.reverse()` (server), `createReverse()` (typed runtime
|
|
12
23
|
* helper), and `useReverse()` (client hook). The behavior must stay
|
|
@@ -17,40 +28,48 @@ export function substitutePatternParams(
|
|
|
17
28
|
params: Record<string, string | undefined>,
|
|
18
29
|
routeName: string,
|
|
19
30
|
): string {
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
const hasTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
32
|
+
const normalized = hasTrailingSlash ? pattern.slice(0, -1) : pattern;
|
|
33
|
+
const segments = parsePattern(normalized);
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// pass `""` explicitly. Treat both as the absent form.
|
|
35
|
+
const parts: string[] = [];
|
|
36
|
+
for (const seg of segments) {
|
|
37
|
+
if (seg.type === "static") {
|
|
38
|
+
parts.push("/" + seg.value);
|
|
39
|
+
} else if (seg.type === "wildcard") {
|
|
40
|
+
const value = params[seg.value];
|
|
30
41
|
if (value === undefined || value === "") {
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
// `:name+` requires at least one segment; bare `*` / `:name*` collapse.
|
|
43
|
+
if (seg.oneOrMore) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Missing param "${seg.value}" for route "${routeName}"`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
parts.push("/" + encodePathRemainder(value));
|
|
33
50
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
} else {
|
|
52
|
+
// Plain param. Constraint (`seg.constraint`) is intentionally not re-emitted.
|
|
53
|
+
const value = params[seg.value];
|
|
54
|
+
const suffix = seg.suffix ?? "";
|
|
55
|
+
if (seg.optional) {
|
|
56
|
+
// The matcher omits absent optionals (`undefined`); callers/getParams()
|
|
57
|
+
// may pass `""` explicitly — treat both as absent.
|
|
58
|
+
if (value !== undefined && value !== "") {
|
|
59
|
+
parts.push("/" + encodePathSegment(value) + suffix);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
if (value === undefined) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Missing param "${seg.value}" for route "${routeName}"`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
parts.push("/" + encodePathSegment(value) + suffix);
|
|
44
68
|
}
|
|
45
|
-
|
|
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 += "/";
|
|
69
|
+
}
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
let result = parts.join("") || "/";
|
|
73
|
+
if (hasTrailingSlash && !result.endsWith("/")) result += "/";
|
|
55
74
|
return result;
|
|
56
75
|
}
|
|
@@ -63,7 +63,8 @@ export function tryTrieMatch(
|
|
|
63
63
|
// same value the regex matcher produces for the bare prefix. Without this
|
|
64
64
|
// the trie misses, the regex fallback runs, and its no-config branch emits
|
|
65
65
|
// a corrupt slice-off redirect. The static terminal still wins above.
|
|
66
|
-
|
|
66
|
+
// A one-or-more catch-all (`w1`, from `:name+`) rejects this empty case.
|
|
67
|
+
if (trie.w && !trie.w.w1) {
|
|
67
68
|
return validateAndBuild(
|
|
68
69
|
trie.w,
|
|
69
70
|
[],
|
|
@@ -192,8 +193,9 @@ function walkTrie(
|
|
|
192
193
|
// walkTrie otherwise only reaches node.w in the index<length branch below,
|
|
193
194
|
// so without this a request to the wildcard's own prefix misses the trie
|
|
194
195
|
// and the regex fallback emits a corrupt redirect. A static terminal
|
|
195
|
-
// (node.r) still wins.
|
|
196
|
-
|
|
196
|
+
// (node.r) still wins. A one-or-more catch-all (`w1`, from `:name+`) rejects
|
|
197
|
+
// this empty case — it requires at least one trailing segment.
|
|
198
|
+
if (node.w && !node.w.w1) {
|
|
197
199
|
const validatedParams = leafConstraintsPass(node.w, paramValues, "");
|
|
198
200
|
if (validatedParams) {
|
|
199
201
|
return {
|
|
@@ -250,14 +252,20 @@ function walkTrie(
|
|
|
250
252
|
|
|
251
253
|
if (node.w) {
|
|
252
254
|
const rest = joinRemainingSegments(segments, index);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
255
|
+
// A one-or-more catch-all (`w1`, from `:name+`) requires at least one
|
|
256
|
+
// non-empty trailing segment. `rest` can still be "" here on a malformed
|
|
257
|
+
// double-slash URL (e.g. `/docs//` splits to a trailing "" segment), so
|
|
258
|
+
// guard this in-path site the same way the root and base-case sites are.
|
|
259
|
+
if (!(node.w.w1 && rest === "")) {
|
|
260
|
+
const validatedParams = leafConstraintsPass(node.w, paramValues, rest);
|
|
261
|
+
if (validatedParams) {
|
|
262
|
+
return {
|
|
263
|
+
leaf: node.w,
|
|
264
|
+
paramValues: [...paramValues],
|
|
265
|
+
wildcardValue: rest,
|
|
266
|
+
validatedParams,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
261
269
|
}
|
|
262
270
|
}
|
|
263
271
|
|
package/src/router/url-params.ts
CHANGED
|
@@ -42,3 +42,16 @@ export function encodePathSegment(value: string): string {
|
|
|
42
42
|
(match) => PATH_SAFE_ESCAPES[match.toUpperCase()] ?? match,
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encode a catch-all remainder: encode each `/`-separated segment but keep the
|
|
48
|
+
* separators, so `a/b c` -> `a/b%20c` (not `a%2Fb%20c`). Shared by the reverse
|
|
49
|
+
* helper and the build-time prerender substitution so both produce identical
|
|
50
|
+
* URLs. `encode` defaults to the path-safe `encodePathSegment`.
|
|
51
|
+
*/
|
|
52
|
+
export function encodePathRemainder(
|
|
53
|
+
value: string,
|
|
54
|
+
encode: (segment: string) => string = encodePathSegment,
|
|
55
|
+
): string {
|
|
56
|
+
return value.split("/").map(encode).join("/");
|
|
57
|
+
}
|