@rangojs/router 0.23.0 → 0.24.0

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 (128) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/context-var.d.ts +12 -0
  11. package/dist/types/errors.d.ts +10 -0
  12. package/dist/types/handles/pushed-by.d.ts +8 -0
  13. package/dist/types/index.d.ts +4 -0
  14. package/dist/types/index.rsc.d.ts +2 -0
  15. package/dist/types/response-utils.d.ts +7 -0
  16. package/dist/types/reverse.d.ts +8 -8
  17. package/dist/types/route-definition/rewrite.d.ts +56 -0
  18. package/dist/types/route-map-builder.d.ts +7 -2
  19. package/dist/types/router/basename.d.ts +7 -0
  20. package/dist/types/router/handler-context.d.ts +1 -5
  21. package/dist/types/router/internal-params.d.ts +8 -0
  22. package/dist/types/router/middleware-types.d.ts +15 -0
  23. package/dist/types/router/revalidation.d.ts +1 -2
  24. package/dist/types/router/route-trie-builder.d.ts +10 -0
  25. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  26. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  27. package/dist/types/rsc/full-payload.d.ts +11 -0
  28. package/dist/types/rsc/handler.d.ts +33 -0
  29. package/dist/types/rsc/helpers.d.ts +8 -0
  30. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  31. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  32. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  33. package/dist/types/server/request-context.d.ts +39 -2
  34. package/dist/types/server/rewrite-trail.d.ts +49 -0
  35. package/dist/types/server/slot-accessors.d.ts +29 -0
  36. package/dist/types/testing/index.d.ts +1 -1
  37. package/dist/types/testing/render-route.d.ts +8 -0
  38. package/dist/types/testing/run-middleware.d.ts +20 -0
  39. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  40. package/dist/types/thenable-status.d.ts +6 -0
  41. package/dist/types/types/request-scope.d.ts +7 -0
  42. package/dist/types/vite/discovery/state.d.ts +12 -9
  43. package/dist/vite/index.js +164 -103
  44. package/package.json +1 -1
  45. package/skills/caching/SKILL.md +12 -0
  46. package/skills/catalog.json +1 -1
  47. package/skills/client-urls/SKILL.md +84 -0
  48. package/skills/hooks/SKILL.md +11 -6
  49. package/skills/hooks/data.md +3 -1
  50. package/skills/loader/SKILL.md +7 -1
  51. package/skills/middleware/SKILL.md +183 -2
  52. package/skills/ppr/SKILL.md +9 -0
  53. package/skills/rango/SKILL.md +1 -1
  54. package/skills/server-actions/SKILL.md +27 -0
  55. package/skills/testing/client-components.md +1 -0
  56. package/skills/testing/middleware.md +31 -10
  57. package/skills/testing/response-routes.md +1 -0
  58. package/skills/testing/serve-shell-request.md +19 -1
  59. package/skills/use-cache/SKILL.md +4 -2
  60. package/src/__augment-tests__/augmented.check.ts +20 -0
  61. package/src/browser/event-controller.ts +3 -0
  62. package/src/browser/merge-segment-loaders.ts +31 -18
  63. package/src/browser/navigation-bridge.ts +19 -3
  64. package/src/browser/partial-update.ts +27 -12
  65. package/src/browser/react/Link.tsx +7 -9
  66. package/src/browser/react/NavigationProvider.tsx +46 -26
  67. package/src/browser/react/context.ts +3 -2
  68. package/src/browser/react/use-router.ts +2 -4
  69. package/src/browser/server-action-bridge.ts +6 -0
  70. package/src/browser/types.ts +5 -1
  71. package/src/build/route-types/scan-filter.ts +11 -4
  72. package/src/cache/document-cache-policy.ts +131 -0
  73. package/src/cache/document-cache.ts +12 -119
  74. package/src/context-var.ts +32 -2
  75. package/src/errors.ts +19 -0
  76. package/src/handles/pushed-by.ts +26 -0
  77. package/src/index.rsc.ts +8 -0
  78. package/src/index.ts +7 -0
  79. package/src/response-utils.ts +19 -0
  80. package/src/reverse.ts +17 -14
  81. package/src/route-definition/redirect.ts +2 -15
  82. package/src/route-definition/rewrite.ts +146 -0
  83. package/src/route-map-builder.ts +14 -11
  84. package/src/router/basename.ts +20 -0
  85. package/src/router/handler-context.ts +3 -13
  86. package/src/router/intercept-resolution.ts +29 -12
  87. package/src/router/internal-params.ts +16 -0
  88. package/src/router/loader-resolution.ts +2 -0
  89. package/src/router/match-middleware/background-revalidation.ts +11 -9
  90. package/src/router/match-middleware/cache-lookup.ts +20 -15
  91. package/src/router/match-middleware/cache-store.ts +9 -11
  92. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  93. package/src/router/middleware-types.ts +16 -0
  94. package/src/router/middleware.ts +127 -49
  95. package/src/router/revalidation.ts +1 -1
  96. package/src/router/route-trie-builder.ts +28 -1
  97. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  98. package/src/router/segment-resolution/mask-nested.ts +8 -4
  99. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  100. package/src/router/segment-resolution/revalidation.ts +57 -33
  101. package/src/rsc/full-payload.ts +37 -5
  102. package/src/rsc/handler.ts +82 -55
  103. package/src/rsc/helpers.ts +24 -0
  104. package/src/rsc/loader-fetch.ts +79 -24
  105. package/src/rsc/response-cache-serve.ts +18 -2
  106. package/src/rsc/response-route-handler.ts +4 -7
  107. package/src/rsc/rewrite-passes.ts +203 -0
  108. package/src/rsc/rsc-rendering.ts +4 -2
  109. package/src/rsc/ssr-setup.ts +11 -5
  110. package/src/server/cookie-parse.ts +36 -12
  111. package/src/server/cookie-store.ts +7 -3
  112. package/src/server/handle-store.ts +48 -17
  113. package/src/server/request-context.ts +154 -63
  114. package/src/server/request-headers.ts +4 -14
  115. package/src/server/rewrite-trail.ts +190 -0
  116. package/src/server/slot-accessors.ts +57 -0
  117. package/src/ssr/index.tsx +118 -16
  118. package/src/testing/dispatch.ts +138 -67
  119. package/src/testing/index.ts +1 -0
  120. package/src/testing/render-route.tsx +16 -3
  121. package/src/testing/run-loader.ts +2 -0
  122. package/src/testing/run-middleware.ts +56 -11
  123. package/src/testing/serve-shell-request.ts +15 -2
  124. package/src/thenable-status.ts +11 -0
  125. package/src/types/request-scope.ts +8 -0
  126. package/src/vite/discovery/state.ts +21 -13
  127. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  128. package/src/vite/router-discovery.ts +29 -27
@@ -125,8 +125,38 @@ export function contextGet(
125
125
  return variables[keyOrVar.key];
126
126
  }
127
127
 
128
- /** Keys that must never be used as string variable names */
129
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
128
+ /**
129
+ * Keys that must never be used as string variable names, nor as a fetchable
130
+ * loader's request params (rsc/loader-fetch.ts).
131
+ */
132
+ export const FORBIDDEN_KEYS: ReadonlySet<string> = new Set([
133
+ "__proto__",
134
+ "constructor",
135
+ "prototype",
136
+ ]);
137
+
138
+ /**
139
+ * `target[key] = value` as an own data property, `__proto__` included: that
140
+ * assignment would call Object.prototype's setter and replace the target's
141
+ * prototype, which Flight refuses to serialize (#1109). Loader data parsed
142
+ * from JSON, cookie names and the like can carry the key.
143
+ */
144
+ export function assignOwn(
145
+ target: Record<string, unknown>,
146
+ key: string,
147
+ value: unknown,
148
+ ): void {
149
+ if (key === "__proto__") {
150
+ Object.defineProperty(target, key, {
151
+ value,
152
+ writable: true,
153
+ enumerable: true,
154
+ configurable: true,
155
+ });
156
+ } else {
157
+ target[key] = value;
158
+ }
159
+ }
130
160
 
131
161
  export interface ContextSetOptions {
132
162
  /**
package/src/errors.ts CHANGED
@@ -296,6 +296,25 @@ export function isSkip(value: unknown): value is Skip {
296
296
  return value instanceof Skip;
297
297
  }
298
298
 
299
+ /**
300
+ * A request middleware rewrote (`throw rewrite()`) more than `maxHops` times,
301
+ * or to the URL it was on. Reported through `onError` (phase "routing") and
302
+ * answered with 508. `trail` lists the URLs, the visitor's first.
303
+ */
304
+ export class RewriteLoopError extends Error {
305
+ name = "RewriteLoopError" as const;
306
+ readonly trail: readonly string[];
307
+
308
+ constructor(trail: readonly string[], maxHops: number) {
309
+ super(
310
+ `Infinite rewrite loop: ${trail.join(" -> ")}. A request is rewritten ` +
311
+ `at most ${maxHops} times, and never to the URL it is on.`,
312
+ );
313
+ Object.setPrototypeOf(this, RewriteLoopError.prototype);
314
+ this.trail = trail;
315
+ }
316
+ }
317
+
299
318
  /**
300
319
  * Thrown by the partial updater when the server responds with a redirect payload
301
320
  * that carries location state. Caught by navigate() to re-navigate to the
@@ -49,6 +49,32 @@ type PushedBy = readonly (string | null)[] | undefined;
49
49
  const keysOf = (a: object | undefined, b: object | undefined): Set<string> =>
50
50
  new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
51
51
 
52
+ /**
53
+ * `target` with the buckets of the segments in `ids` taken from `source`.
54
+ * A pushedBy record is a snapshot key like a handle's, so a bucket's values
55
+ * and the record of the runs that pushed them always come from the same
56
+ * snapshot. New containers; the value arrays are shared, as cloneHandleData
57
+ * (navigation-store.ts) shares them.
58
+ */
59
+ export function withSegmentHandles(
60
+ target: HandleData,
61
+ source: HandleData,
62
+ ids: ReadonlySet<string>,
63
+ ): HandleData {
64
+ const out: HandleData = {};
65
+ for (const name of keysOf(target, source)) {
66
+ const buckets: Record<string, unknown[]> = {};
67
+ for (const [id, values] of Object.entries(target[name] ?? {})) {
68
+ if (!ids.has(id)) buckets[id] = values;
69
+ }
70
+ for (const [id, values] of Object.entries(source[name] ?? {})) {
71
+ if (ids.has(id)) buckets[id] = values;
72
+ }
73
+ if (Object.keys(buckets).length > 0) out[name] = buckets;
74
+ }
75
+ return out;
76
+ }
77
+
52
78
  /**
53
79
  * The handle data after a partial response's snapshot `incoming` (each stream
54
80
  * yield holds everything the response pushed so far). A segment outside
package/src/index.rsc.ts CHANGED
@@ -119,6 +119,14 @@ export {
119
119
  transition,
120
120
  } from "./route-definition.js";
121
121
 
122
+ // Render another route at the same URL from middleware (throw rewrite()).
123
+ export {
124
+ rewrite,
125
+ type Rewrite,
126
+ type RewriteFunction,
127
+ } from "./route-definition/rewrite.js";
128
+ export { RewriteLoopError } from "./errors.js";
129
+
122
130
  // Composition types for reusable callback factories
123
131
  export type {
124
132
  RouteUseItem,
package/src/index.ts CHANGED
@@ -155,6 +155,13 @@ export function redirect(): never {
155
155
  throw serverOnlyStubError("redirect");
156
156
  }
157
157
 
158
+ /**
159
+ * Error-throwing stub for server-only `rewrite` function.
160
+ */
161
+ export function rewrite(): never {
162
+ throw serverOnlyStubError("rewrite");
163
+ }
164
+
158
165
  // Handle API (universal - works on both server and client)
159
166
  export { createHandle, isHandle, type Handle } from "./handle.js";
160
167
  export {
@@ -60,3 +60,22 @@ export function isRedirectResponse(response: Response): boolean {
60
60
  Boolean(response.headers.get("Location"))
61
61
  );
62
62
  }
63
+
64
+ /**
65
+ * Set a header on a response the router hands to the host, skipping a
66
+ * WebSocket upgrade (its headers are not to be touched) and swallowing the
67
+ * TypeError of immutable headers (a passed-through platform Response):
68
+ * instrumentation and debug headers never fail a request.
69
+ */
70
+ export function trySetHeader(
71
+ response: Response,
72
+ name: string,
73
+ value: string,
74
+ ): void {
75
+ if (isWebSocketUpgradeResponse(response)) return;
76
+ try {
77
+ response.headers.set(name, value);
78
+ } catch {
79
+ // Immutable headers: drop the header, never the response.
80
+ }
81
+ }
package/src/reverse.ts CHANGED
@@ -149,6 +149,7 @@ export type ReverseFunction<TRoutes> = {
149
149
  export type ScopedReverseFunction<
150
150
  TLocalRoutes,
151
151
  TGlobalRoutes = TLocalRoutes,
152
+ TResult = string,
152
153
  > = {
153
154
  /**
154
155
  * Global route without params
@@ -159,7 +160,7 @@ export type ScopedReverseFunction<
159
160
  > extends true
160
161
  ? TName
161
162
  : never,
162
- ): string;
163
+ ): TResult;
163
164
 
164
165
  /**
165
166
  * Global route with params
@@ -167,7 +168,7 @@ export type ScopedReverseFunction<
167
168
  <TName extends keyof TGlobalRoutes & string>(
168
169
  name: TName,
169
170
  params: ExtractParams<RoutePatternFor<TGlobalRoutes, TName>>,
170
- ): string;
171
+ ): TResult;
171
172
 
172
173
  /**
173
174
  * Global route with params and search
@@ -176,7 +177,7 @@ export type ScopedReverseFunction<
176
177
  name: TName,
177
178
  params: ExtractParams<RoutePatternFor<TGlobalRoutes, TName>>,
178
179
  search: ResolveSearchSchema<ExtractSearchSchema<TGlobalRoutes, TName>>,
179
- ): string;
180
+ ): TResult;
180
181
 
181
182
  /**
182
183
  * Dot-prefixed local route without params
@@ -187,7 +188,7 @@ export type ScopedReverseFunction<
187
188
  > extends true
188
189
  ? `.${TName}`
189
190
  : never,
190
- ): string;
191
+ ): TResult;
191
192
 
192
193
  /**
193
194
  * Dot-prefixed local route with params
@@ -195,7 +196,7 @@ export type ScopedReverseFunction<
195
196
  <TName extends keyof TLocalRoutes & string>(
196
197
  name: `.${TName}`,
197
198
  params: ExtractParams<RoutePatternFor<TLocalRoutes, TName>>,
198
- ): string;
199
+ ): TResult;
199
200
 
200
201
  /**
201
202
  * Dot-prefixed local route with params and search
@@ -204,7 +205,7 @@ export type ScopedReverseFunction<
204
205
  name: `.${TName}`,
205
206
  params: ExtractParams<RoutePatternFor<TLocalRoutes, TName>>,
206
207
  search: ResolveSearchSchema<ExtractSearchSchema<TLocalRoutes, TName>>,
207
- ): string;
208
+ ): TResult;
208
209
  };
209
210
 
210
211
  /**
@@ -235,14 +236,16 @@ export type ScopedReverseFunction<
235
236
  * reverse(".post", { slug: "hello" }) // compile error: no include() scope
236
237
  * ```
237
238
  */
238
- export type GlobalReverseFunction<TGlobalRoutes> =
239
- string extends keyof TGlobalRoutes
240
- ? <TName extends string>(
241
- name: Exclude<TName, `.${string}`>,
242
- params?: Record<string, string>,
243
- search?: Record<string, unknown>,
244
- ) => string
245
- : ScopedReverseFunction<{}, TGlobalRoutes>;
239
+ export type GlobalReverseFunction<
240
+ TGlobalRoutes,
241
+ TResult = string,
242
+ > = string extends keyof TGlobalRoutes
243
+ ? <TName extends string>(
244
+ name: Exclude<TName, `.${string}`>,
245
+ params?: Record<string, string>,
246
+ search?: Record<string, unknown>,
247
+ ) => TResult
248
+ : ScopedReverseFunction<{}, TGlobalRoutes, TResult>;
246
249
 
247
250
  /**
248
251
  * Extract local routes type from UrlPatterns
@@ -8,6 +8,7 @@ import {
8
8
  attachRedirectState,
9
9
  markExternalRedirect,
10
10
  } from "../redirect-origin.js";
11
+ import { prefixBasename } from "../router/basename.js";
11
12
 
12
13
  /**
13
14
  * Create a soft redirect Response for middleware short-circuit
@@ -84,21 +85,7 @@ export function redirect(
84
85
  }
85
86
 
86
87
  // Auto-prefix root-relative URLs with basename for app-local redirects.
87
- // Treat the URL as already-prefixed when the basename is followed by a path
88
- // separator, a query, a fragment, or end-of-string, so "/admin?tab=x" and
89
- // "/admin#frag" are not double-prefixed into "/admin/admin?tab=x".
90
- const bn = _getRequestContext()?._basename;
91
- let resolvedUrl = url;
92
- if (
93
- bn &&
94
- url.startsWith("/") &&
95
- url !== bn &&
96
- !url.startsWith(bn + "/") &&
97
- !url.startsWith(bn + "?") &&
98
- !url.startsWith(bn + "#")
99
- ) {
100
- resolvedUrl = url === "/" ? bn : bn + url;
101
- }
88
+ const resolvedUrl = prefixBasename(url, _getRequestContext()?._basename);
102
89
 
103
90
  const headers: Record<string, string> = {
104
91
  Location: resolvedUrl,
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `throw rewrite(target)`: middleware renders another route at the same URL.
3
+ *
4
+ * The value only: what middleware throws and how the router recognizes it.
5
+ * The pass loop that honors it lives in rsc/rewrite-passes.ts, the hop trail
6
+ * in server/rewrite-trail.ts.
7
+ */
8
+
9
+ import type { GlobalReverseFunction } from "../reverse.js";
10
+ import type { DefaultReverseRouteMap } from "../types/global-namespace.js";
11
+
12
+ /**
13
+ * Cross-realm brand: a Vite dev server can load this module twice (the RSC
14
+ * and the SSR graph), so `instanceof` alone misses a Rewrite from the other
15
+ * copy.
16
+ */
17
+ const REWRITE_BRAND: unique symbol = Symbol.for("rango.rewrite");
18
+
19
+ /** The target as written: a path, or a global route name with its params. */
20
+ export type RewriteTarget =
21
+ | { readonly path: string }
22
+ | {
23
+ readonly name: string;
24
+ readonly params?: Record<string, string>;
25
+ readonly search?: Record<string, unknown>;
26
+ };
27
+
28
+ const REWRITE_RULE =
29
+ "rewrite() is for middleware: throw it from router.use() or route " +
30
+ "middleware (middleware() in urls()), before await next(), while it " +
31
+ "handles a GET or HEAD page request. Loaders and route handlers can't " +
32
+ "rewrite: throw rewrite() from the route's middleware instead. Intercept " +
33
+ "middleware and loader fetches can't either. In a server action use " +
34
+ "redirect(); for a mutation return a Response from middleware.";
35
+
36
+ export function describeRewriteTarget(target: RewriteTarget): string {
37
+ return "path" in target
38
+ ? `rewrite(${JSON.stringify(target.path)})`
39
+ : `rewrite(${JSON.stringify(target.name)})`;
40
+ }
41
+
42
+ /**
43
+ * The value `rewrite()` returns, thrown by middleware. An Error, so a throw
44
+ * where a rewrite is not supported fails loudly with the rule in its message.
45
+ */
46
+ export class Rewrite extends Error {
47
+ name = "Rewrite" as const;
48
+ readonly target: RewriteTarget;
49
+
50
+ constructor(target: RewriteTarget) {
51
+ super(`${describeRewriteTarget(target)}: ${REWRITE_RULE}`);
52
+ Object.setPrototypeOf(this, Rewrite.prototype);
53
+ this.target = target;
54
+ }
55
+ }
56
+ Object.defineProperty(Rewrite.prototype, REWRITE_BRAND, { value: true });
57
+
58
+ /** Whether `value` is a Rewrite thrown by `rewrite()` (ctx.isRewrite). */
59
+ export function isRewrite(value: unknown): value is Rewrite {
60
+ return (
61
+ typeof value === "object" &&
62
+ value !== null &&
63
+ (value as { [REWRITE_BRAND]?: unknown })[REWRITE_BRAND] === true
64
+ );
65
+ }
66
+
67
+ /**
68
+ * `rewrite(path)` or `rewrite(name, params?, search?)`: a path in this app
69
+ * (basename-prefixed like `redirect()`, may carry `?search`), or a global
70
+ * route name with the params and search `ctx.reverse` takes.
71
+ */
72
+ export type RewriteFunction<TRoutes = DefaultReverseRouteMap> = ((
73
+ path: `/${string}`,
74
+ ) => Rewrite) &
75
+ GlobalReverseFunction<TRoutes, Rewrite>;
76
+
77
+ function createRewrite(
78
+ target: string,
79
+ params?: Record<string, string>,
80
+ search?: Record<string, unknown>,
81
+ ): Rewrite {
82
+ if (typeof target !== "string" || target === "") {
83
+ throw new TypeError(
84
+ 'rewrite() takes a path starting with "/" or a global route name.',
85
+ );
86
+ }
87
+ if (target.startsWith("/")) {
88
+ if (params !== undefined || search !== undefined) {
89
+ throw new TypeError(
90
+ `rewrite(${JSON.stringify(target)}) takes params only with a route name, not with a path.`,
91
+ );
92
+ }
93
+ return new Rewrite({ path: target });
94
+ }
95
+ if (target.startsWith(".")) {
96
+ throw new TypeError(
97
+ `rewrite(${JSON.stringify(target)}): a rewrite resolves global route names only (no include() scope).`,
98
+ );
99
+ }
100
+ return new Rewrite({ name: target, params, search });
101
+ }
102
+
103
+ /**
104
+ * Render another route at the same URL. Throw it from middleware, before
105
+ * `await next()`:
106
+ *
107
+ * ```ts
108
+ * router.use(async (ctx, next) => {
109
+ * if (!locked || ctx.pathname === "/maintenance") return next();
110
+ * throw rewrite("maintenance");
111
+ * });
112
+ * ```
113
+ *
114
+ * The request runs again as the target (global middleware, route
115
+ * middleware, render) with the same request context: values set before the
116
+ * throw stay readable. `ctx.url` is the target; `ctx.rewrittenFrom` is the
117
+ * visitor's URL. See the `/middleware` skill.
118
+ */
119
+ export const rewrite: RewriteFunction = createRewrite as RewriteFunction;
120
+
121
+ /** A dispatch site that cannot honor a rewrite. */
122
+ export type RewriteSite = "mutation" | "loader-fetch" | "intercept";
123
+
124
+ const SITE_MESSAGE: Record<RewriteSite, string> = {
125
+ mutation:
126
+ "middleware threw it while handling a non-GET request (a server action " +
127
+ "or a form post). Return a Response for mutations instead, e.g. " +
128
+ 'new Response("Maintenance", { status: 503 }).',
129
+ "loader-fetch":
130
+ "middleware threw it during a loader fetch (useFetchLoader, load(), " +
131
+ "useRefreshLoaders). Return a Response there.",
132
+ intercept:
133
+ "intercept middleware threw it. Rewrite from router.use() or the " +
134
+ "route's middleware(), or return a Response.",
135
+ };
136
+
137
+ /** The error a rewrite becomes at a site that cannot honor it. */
138
+ export function rewriteNotSupported(
139
+ rewrite: Rewrite,
140
+ site: RewriteSite,
141
+ ): Error {
142
+ return new Error(
143
+ `${describeRewriteTarget(rewrite.target)} is not supported here: ${SITE_MESSAGE[site]}`,
144
+ { cause: rewrite },
145
+ );
146
+ }
@@ -8,6 +8,11 @@
8
8
  * See docs/manifests.md for the full data flow.
9
9
  */
10
10
 
11
+ import {
12
+ nullPrototypeStaticChildren,
13
+ type TrieNode,
14
+ } from "./router/route-trie-builder.js";
15
+
11
16
  let globalRouteMap: Record<string, string> = {};
12
17
 
13
18
  let cachedManifest: Record<string, string> | null = null;
@@ -86,8 +91,7 @@ export function clearCachedManifest(): void {
86
91
  // precomputed entries so multi-router setups (e.g. site + admin via
87
92
  // createHostRouter()) don't see each other's routes.
88
93
  const perRouterManifestMap: Map<string, Record<string, string>> = new Map();
89
- const perRouterTrieMap: Map<string, import("./build/route-trie.js").TrieNode> =
90
- new Map();
94
+ const perRouterTrieMap: Map<string, TrieNode> = new Map();
91
95
  const perRouterPrecomputedEntriesMap: Map<
92
96
  string,
93
97
  Array<{ staticPrefix: string; routes: Record<string, string> }>
@@ -132,11 +136,12 @@ export function getRouterManifest(
132
136
  return perRouterManifestMap.get(routerId);
133
137
  }
134
138
 
135
- export function setRouterTrie(
136
- routerId: string,
137
- trie: import("./build/route-trie.js").TrieNode,
138
- ): void {
139
- perRouterTrieMap.set(routerId, trie);
139
+ /**
140
+ * Install a router's trie. A trie that arrives as JSON (the build manifest)
141
+ * or across the dev runner boundary gets null-prototype static maps first.
142
+ */
143
+ export function setRouterTrie(routerId: string, trie: TrieNode): void {
144
+ perRouterTrieMap.set(routerId, nullPrototypeStaticChildren(trie));
140
145
  }
141
146
 
142
147
  // Routers whose trie came from the COMPLETE build manifest (deserialized via
@@ -156,9 +161,7 @@ export function isRouterTrieAuthoritative(routerId: string): boolean {
156
161
  return authoritativeTrieRouters.has(routerId);
157
162
  }
158
163
 
159
- export function getRouterTrie(
160
- routerId: string,
161
- ): import("./build/route-trie.js").TrieNode | undefined {
164
+ export function getRouterTrie(routerId: string): TrieNode | undefined {
162
165
  return perRouterTrieMap.get(routerId);
163
166
  }
164
167
 
@@ -199,7 +202,7 @@ export async function ensureRouterManifest(routerId: string): Promise<void> {
199
202
  if (loader) {
200
203
  const mod = await loader();
201
204
  if (mod.trie) {
202
- perRouterTrieMap.set(routerId, mod.trie);
205
+ setRouterTrie(routerId, mod.trie);
203
206
  // A trie serialized into the build manifest comes from complete
204
207
  // discovery — misses are authoritative 404s (see find-match.ts).
205
208
  markRouterTrieAuthoritative(routerId);
@@ -12,3 +12,23 @@ export function normalizeBasename(basename?: string): string | undefined {
12
12
  const trimmed = basename.replace(/^\/+|\/+$/g, "");
13
13
  return trimmed ? "/" + trimmed : undefined;
14
14
  }
15
+
16
+ /**
17
+ * Prefix a root-relative path with the basename unless it already carries it
18
+ * (the basename followed by "/", "?", "#" or the end), so "/admin?tab=x" and
19
+ * "/admin#frag" under basename "/admin" are not prefixed twice. Shared by
20
+ * redirect(), rewrite() and useRouter().
21
+ */
22
+ export function prefixBasename(path: string, basename?: string): string {
23
+ if (
24
+ !basename ||
25
+ !path.startsWith("/") ||
26
+ path === basename ||
27
+ path.startsWith(basename + "/") ||
28
+ path.startsWith(basename + "?") ||
29
+ path.startsWith(basename + "#")
30
+ ) {
31
+ return path;
32
+ }
33
+ return path === "/" ? basename : basename + path;
34
+ }
@@ -30,6 +30,7 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
30
30
  import { PRERENDER_PASSTHROUGH } from "../prerender.js";
31
31
  import { substitutePatternParams } from "./substitute-pattern-params.js";
32
32
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
33
+ import { defineRewrittenFrom } from "../server/rewrite-trail.js";
33
34
 
34
35
  // Mutating Headers methods guarded so they throw inside "use cache" / cache()
35
36
  // scope. Module-level constant (read-only, .has() lookups) so it is allocated
@@ -87,19 +88,7 @@ export function guardHeaderWrites(
87
88
  });
88
89
  }
89
90
 
90
- /**
91
- * Strip internal _rsc* query params from a URL.
92
- * Returns a new URL with only user-facing params.
93
- */
94
- export function stripInternalParams(url: URL): URL {
95
- const clean = new URL(url);
96
- for (const key of [...clean.searchParams.keys()]) {
97
- if (key.startsWith("_rsc")) {
98
- clean.searchParams.delete(key);
99
- }
100
- }
101
- return clean;
102
- }
91
+ export { stripInternalParams } from "./internal-params.js";
103
92
 
104
93
  /**
105
94
  * Resolve route name with namespace prefix support.
@@ -362,6 +351,7 @@ export function createHandlerContext<TEnv>(
362
351
  (ctx as { [THEME_REQUEST_CONTEXT]?: unknown })[THEME_REQUEST_CONTEXT] =
363
352
  requestContext;
364
353
  Object.defineProperty(ctx, "theme", THEME_DESCRIPTOR);
354
+ defineRewrittenFrom(ctx, requestContext);
365
355
  // Brand with taint symbol so "use cache" excludes ctx from cache keys
366
356
  (ctx as any)[NOCACHE_SYMBOL] = true;
367
357
  return ctx;
@@ -262,13 +262,23 @@ export async function resolveInterceptEntry<TEnv>(
262
262
 
263
263
  latchPprHeaderScopeForInterceptTarget(interceptEntry);
264
264
 
265
+ // The intercept segment's id: the tag scope its handler and layout record in
266
+ // (#957, cache-tag.ts runInSegmentTagScope) and the owner of its handler's
267
+ // and loaders' handle pushes, pinned before the loader kickoffs as
268
+ // fresh.ts/revalidation.ts pin theirs (createLoaderExecutor captures it at
269
+ // kickoff; a handler's ctx.use(Handle) reads it when called).
270
+ const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
271
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
272
+ slotSegmentId;
273
+ const hasLoaders = interceptEntry.loader.length > 0;
274
+
265
275
  const loaderPromises: Promise<any>[] = [];
266
276
  const loaderIds: string[] = [];
267
277
 
268
278
  for (let i = 0; i < interceptEntry.loader.length; i++) {
269
279
  const loaderEntry = interceptEntry.loader[i];
270
280
  const { loader, revalidate: loaderRevalidateFns } = loaderEntry;
271
- const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
281
+ const segmentId = `${slotSegmentId}D${i}.${loader.$$id}`;
272
282
 
273
283
  if (revalidationContext) {
274
284
  const {
@@ -282,8 +292,7 @@ export async function resolveInterceptEntry<TEnv>(
282
292
  stale,
283
293
  } = revalidationContext;
284
294
 
285
- const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
286
- if (clientSegmentIds.has(interceptSegmentId)) {
295
+ if (clientSegmentIds.has(slotSegmentId)) {
287
296
  const dummySegment: ResolvedSegment = {
288
297
  id: segmentId,
289
298
  namespace: `intercept:${interceptEntry.routeName}`,
@@ -345,9 +354,6 @@ export async function resolveInterceptEntry<TEnv>(
345
354
  );
346
355
  }
347
356
 
348
- // The intercept segment's id, and the tag scope its handler and layout
349
- // record in (#957, cache-tag.ts runInSegmentTagScope).
350
- const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
351
357
  let handlerResult: ReactNode;
352
358
  try {
353
359
  const handler = interceptEntry.handler;
@@ -399,7 +405,7 @@ export async function resolveInterceptEntry<TEnv>(
399
405
  const streamedComponent = (promise: Promise<unknown>): ReactNode =>
400
406
  trackStreamedHandler(deps, promise, {
401
407
  boundaryEntry: parentEntry,
402
- segmentId: `${parentEntry.shortCode}.${interceptEntry.slotName}`,
408
+ segmentId: slotSegmentId,
403
409
  segmentType: "parallel",
404
410
  context,
405
411
  routeKey: interceptEntry.routeName,
@@ -442,6 +448,13 @@ export async function resolveInterceptEntry<TEnv>(
442
448
  }
443
449
  }
444
450
 
451
+ // The aggregate lists the loaders that ran; empty, not absent, when the
452
+ // client holds the slot and all skipped. Absent replaces the client's
453
+ // aggregate, empty merges into it (merge-segment-loaders.ts).
454
+ if (hasLoaders && loaderPromises.length === 0) {
455
+ loaderDataPromise = [];
456
+ }
457
+
445
458
  const interceptSegment = {
446
459
  id: slotSegmentId,
447
460
  namespace: `intercept:${interceptEntry.routeName}`,
@@ -455,7 +468,7 @@ export async function resolveInterceptEntry<TEnv>(
455
468
  belongsToRoute,
456
469
  parallelName: `intercept:${interceptEntry.routeName}.${interceptEntry.slotName}`,
457
470
  loaderDataPromise,
458
- loaderIds: loaderIds.length > 0 ? loaderIds : undefined,
471
+ loaderIds: hasLoaders ? loaderIds : undefined,
459
472
  };
460
473
  segments.push(interceptSegment);
461
474
 
@@ -498,6 +511,10 @@ export async function resolveInterceptLoadersOnly<TEnv>(
498
511
 
499
512
  latchPprHeaderScopeForInterceptTarget(interceptEntry);
500
513
 
514
+ // The loaders' handle push owner, as in resolveInterceptEntry.
515
+ const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
516
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
517
+ slotSegmentId;
501
518
  const loaderPromises: Promise<any>[] = [];
502
519
  const loaderIds: string[] = [];
503
520
 
@@ -515,10 +532,9 @@ export async function resolveInterceptLoadersOnly<TEnv>(
515
532
  for (let i = 0; i < interceptEntry.loader.length; i++) {
516
533
  const loaderEntry = interceptEntry.loader[i];
517
534
  const { loader, revalidate: loaderRevalidateFns } = loaderEntry;
518
- const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
535
+ const segmentId = `${slotSegmentId}D${i}.${loader.$$id}`;
519
536
 
520
- const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
521
- if (clientSegmentIds.has(interceptSegmentId)) {
537
+ if (clientSegmentIds.has(slotSegmentId)) {
522
538
  const dummySegment: ResolvedSegment = {
523
539
  id: segmentId,
524
540
  namespace: `intercept:${interceptEntry.routeName}`,
@@ -581,8 +597,9 @@ export async function resolveInterceptLoadersOnly<TEnv>(
581
597
  );
582
598
  }
583
599
 
600
+ // Every loader held: the empty aggregate (see resolveInterceptEntry).
584
601
  if (loaderPromises.length === 0) {
585
- return null;
602
+ return { loaderDataPromise: [], loaderIds };
586
603
  }
587
604
 
588
605
  // Match fresh-path semantics: only defer (no await) when loading is truthy.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Strip internal _rsc* query params from a URL.
3
+ * Returns a new URL with only user-facing params.
4
+ *
5
+ * A leaf, so request-scoped modules (server/rewrite-trail.ts) can strip
6
+ * without importing the handler context's graph.
7
+ */
8
+ export function stripInternalParams(url: URL): URL {
9
+ const clean = new URL(url);
10
+ for (const key of [...clean.searchParams.keys()]) {
11
+ if (key.startsWith("_rsc")) {
12
+ clean.searchParams.delete(key);
13
+ }
14
+ }
15
+ return clean;
16
+ }