@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.
Files changed (45) hide show
  1. package/dist/bin/rango.js +27 -2
  2. package/dist/vite/index.js +147 -30
  3. package/package.json +1 -1
  4. package/skills/breadcrumbs/SKILL.md +1 -1
  5. package/skills/cache-guide/SKILL.md +1 -0
  6. package/skills/caching/SKILL.md +1 -1
  7. package/skills/migrate-nextjs/SKILL.md +15 -0
  8. package/skills/migrate-react-router/SKILL.md +15 -2
  9. package/skills/ppr/SKILL.md +426 -0
  10. package/skills/rango/SKILL.md +28 -25
  11. package/skills/route/SKILL.md +43 -0
  12. package/src/build/route-trie.ts +35 -7
  13. package/src/cache/cf/cf-cache-store.ts +155 -0
  14. package/src/cache/index.ts +6 -0
  15. package/src/cache/memory-segment-store.ts +57 -1
  16. package/src/cache/shell-cache.ts +386 -0
  17. package/src/cache/types.ts +58 -0
  18. package/src/cache/vercel/vercel-cache-store.ts +159 -5
  19. package/src/index.rsc.ts +5 -0
  20. package/src/index.ts +17 -0
  21. package/src/router/middleware.ts +14 -5
  22. package/src/router/parse-pattern.ts +115 -0
  23. package/src/router/pattern-matching.ts +53 -64
  24. package/src/router/segment-resolution/fresh.ts +12 -1
  25. package/src/router/segment-resolution/loader-cache.ts +14 -0
  26. package/src/router/segment-resolution/loader-mask.ts +44 -0
  27. package/src/router/substitute-pattern-params.ts +54 -35
  28. package/src/router/trie-matching.ts +19 -11
  29. package/src/router/url-params.ts +13 -0
  30. package/src/rsc/full-payload.ts +70 -0
  31. package/src/rsc/rsc-rendering.ts +105 -51
  32. package/src/rsc/shell-capture.ts +439 -0
  33. package/src/rsc/types.ts +26 -0
  34. package/src/server/cookie-store.ts +45 -0
  35. package/src/server/live.ts +130 -0
  36. package/src/server/request-context.ts +49 -0
  37. package/src/ssr/index.tsx +377 -180
  38. package/src/ssr/ssr-root.tsx +228 -0
  39. package/src/testing/render-route.tsx +7 -9
  40. package/src/types/route-config.ts +19 -7
  41. package/src/urls/type-extraction.ts +43 -18
  42. package/src/vite/discovery/discovery-errors.ts +61 -0
  43. package/src/vite/plugins/virtual-entries.ts +27 -2
  44. package/src/vite/router-discovery.ts +69 -15
  45. 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
@@ -104,11 +104,20 @@ export function compileMiddlewarePattern(pattern: string): {
104
104
  const segment = segments[i];
105
105
 
106
106
  if (segment.type === "wildcard") {
107
- // Optional subtree match (parity with the original middleware parser,
108
- // which compiled every `*` as `(?:/.*)?`). A trailing `*` matches the
109
- // subtree; a non-trailing `*` matches zero-or-more intermediate segments,
110
- // so `/a/<star>/b` still matches `/a/b`.
111
- regexStr += "(?:/.*)?";
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
- * Parsed segment info
15
- */
16
- export interface ParsedSegment {
17
- type: "static" | "param" | "wildcard";
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
- paramNames.push("*");
152
- regexPattern += "/(.*)";
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 (!loadingDisabled) {
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`. 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.
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
- let result = pattern;
21
- let hadOmittedOptional = false;
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
- 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.
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
- hadOmittedOptional = true;
32
- return "";
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
- 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}"`);
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
- 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 += "/";
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
- if (trie.w) {
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
- if (node.w) {
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
- const validatedParams = leafConstraintsPass(node.w, paramValues, rest);
254
- if (validatedParams) {
255
- return {
256
- leaf: node.w,
257
- paramValues: [...paramValues],
258
- wildcardValue: rest,
259
- validatedParams,
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
 
@@ -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
+ }