@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19

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 (177) hide show
  1. package/README.md +46 -8
  2. package/dist/bin/rango.js +105 -18
  3. package/dist/vite/index.js +227 -93
  4. package/package.json +15 -14
  5. package/skills/hooks/SKILL.md +1 -1
  6. package/skills/intercept/SKILL.md +79 -0
  7. package/skills/layout/SKILL.md +62 -2
  8. package/skills/loader/SKILL.md +94 -1
  9. package/skills/middleware/SKILL.md +81 -0
  10. package/skills/parallel/SKILL.md +57 -2
  11. package/skills/prerender/SKILL.md +187 -17
  12. package/skills/route/SKILL.md +42 -1
  13. package/skills/router-setup/SKILL.md +77 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/bin/rango.ts +38 -19
  16. package/src/browser/action-coordinator.ts +97 -0
  17. package/src/browser/event-controller.ts +25 -27
  18. package/src/browser/history-state.ts +80 -0
  19. package/src/browser/intercept-utils.ts +1 -1
  20. package/src/browser/link-interceptor.ts +0 -3
  21. package/src/browser/merge-segment-loaders.ts +9 -2
  22. package/src/browser/navigation-bridge.ts +46 -13
  23. package/src/browser/navigation-client.ts +32 -61
  24. package/src/browser/navigation-store.ts +1 -31
  25. package/src/browser/navigation-transaction.ts +46 -207
  26. package/src/browser/partial-update.ts +102 -150
  27. package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
  28. package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
  29. package/src/browser/prefetch/policy.ts +42 -0
  30. package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
  31. package/src/browser/react/Link.tsx +28 -23
  32. package/src/browser/react/NavigationProvider.tsx +9 -1
  33. package/src/browser/react/index.ts +2 -6
  34. package/src/browser/react/location-state-shared.ts +1 -1
  35. package/src/browser/react/location-state.ts +2 -0
  36. package/src/browser/react/nonce-context.ts +23 -0
  37. package/src/browser/react/use-action.ts +9 -1
  38. package/src/browser/react/use-handle.ts +3 -25
  39. package/src/browser/react/use-params.ts +2 -4
  40. package/src/browser/react/use-pathname.ts +2 -3
  41. package/src/browser/react/use-router.ts +1 -1
  42. package/src/browser/react/use-search-params.ts +2 -1
  43. package/src/browser/react/use-segments.ts +7 -60
  44. package/src/browser/response-adapter.ts +73 -0
  45. package/src/browser/rsc-router.tsx +29 -23
  46. package/src/browser/scroll-restoration.ts +10 -7
  47. package/src/browser/server-action-bridge.ts +115 -96
  48. package/src/browser/types.ts +1 -31
  49. package/src/browser/validate-redirect-origin.ts +29 -0
  50. package/src/build/generate-manifest.ts +5 -0
  51. package/src/build/generate-route-types.ts +2 -0
  52. package/src/build/route-types/codegen.ts +13 -4
  53. package/src/build/route-types/include-resolution.ts +13 -0
  54. package/src/build/route-types/per-module-writer.ts +15 -3
  55. package/src/build/route-types/router-processing.ts +45 -3
  56. package/src/build/runtime-discovery.ts +13 -1
  57. package/src/cache/background-task.ts +34 -0
  58. package/src/cache/cache-key-utils.ts +44 -0
  59. package/src/cache/cache-policy.ts +125 -0
  60. package/src/cache/cache-runtime.ts +132 -96
  61. package/src/cache/cache-scope.ts +71 -73
  62. package/src/cache/cf/cf-cache-store.ts +9 -4
  63. package/src/cache/document-cache.ts +72 -47
  64. package/src/cache/handle-capture.ts +81 -0
  65. package/src/cache/memory-segment-store.ts +18 -7
  66. package/src/cache/profile-registry.ts +43 -8
  67. package/src/cache/read-through-swr.ts +134 -0
  68. package/src/cache/segment-codec.ts +101 -112
  69. package/src/cache/taint.ts +26 -0
  70. package/src/client.tsx +53 -30
  71. package/src/errors.ts +6 -1
  72. package/src/handle.ts +1 -1
  73. package/src/handles/MetaTags.tsx +5 -2
  74. package/src/host/cookie-handler.ts +8 -3
  75. package/src/host/router.ts +14 -1
  76. package/src/href-client.ts +3 -1
  77. package/src/index.rsc.ts +33 -1
  78. package/src/index.ts +27 -0
  79. package/src/loader.rsc.ts +12 -4
  80. package/src/loader.ts +8 -0
  81. package/src/prerender/store.ts +4 -3
  82. package/src/prerender.ts +76 -18
  83. package/src/reverse.ts +11 -7
  84. package/src/root-error-boundary.tsx +30 -26
  85. package/src/route-definition/dsl-helpers.ts +9 -6
  86. package/src/route-definition/redirect.ts +15 -3
  87. package/src/route-map-builder.ts +38 -2
  88. package/src/route-name.ts +53 -0
  89. package/src/route-types.ts +7 -0
  90. package/src/router/content-negotiation.ts +1 -1
  91. package/src/router/debug-manifest.ts +16 -3
  92. package/src/router/handler-context.ts +94 -15
  93. package/src/router/intercept-resolution.ts +6 -4
  94. package/src/router/lazy-includes.ts +4 -0
  95. package/src/router/loader-resolution.ts +1 -0
  96. package/src/router/logging.ts +100 -3
  97. package/src/router/manifest.ts +32 -3
  98. package/src/router/match-api.ts +61 -7
  99. package/src/router/match-context.ts +3 -0
  100. package/src/router/match-handlers.ts +185 -11
  101. package/src/router/match-middleware/background-revalidation.ts +65 -85
  102. package/src/router/match-middleware/cache-lookup.ts +69 -4
  103. package/src/router/match-middleware/cache-store.ts +2 -0
  104. package/src/router/match-pipelines.ts +8 -43
  105. package/src/router/middleware-types.ts +7 -0
  106. package/src/router/middleware.ts +93 -8
  107. package/src/router/pattern-matching.ts +41 -5
  108. package/src/router/prerender-match.ts +34 -6
  109. package/src/router/preview-match.ts +7 -1
  110. package/src/router/revalidation.ts +61 -2
  111. package/src/router/router-context.ts +15 -0
  112. package/src/router/router-interfaces.ts +34 -0
  113. package/src/router/router-options.ts +200 -0
  114. package/src/router/segment-resolution/fresh.ts +123 -30
  115. package/src/router/segment-resolution/helpers.ts +19 -0
  116. package/src/router/segment-resolution/loader-cache.ts +37 -146
  117. package/src/router/segment-resolution/revalidation.ts +358 -94
  118. package/src/router/segment-wrappers.ts +3 -0
  119. package/src/router/telemetry-otel.ts +299 -0
  120. package/src/router/telemetry.ts +300 -0
  121. package/src/router/timeout.ts +148 -0
  122. package/src/router/types.ts +7 -1
  123. package/src/router.ts +155 -11
  124. package/src/rsc/handler-context.ts +11 -0
  125. package/src/rsc/handler.ts +380 -88
  126. package/src/rsc/helpers.ts +25 -16
  127. package/src/rsc/loader-fetch.ts +84 -42
  128. package/src/rsc/origin-guard.ts +141 -0
  129. package/src/rsc/progressive-enhancement.ts +232 -19
  130. package/src/rsc/response-route-handler.ts +37 -26
  131. package/src/rsc/rsc-rendering.ts +12 -5
  132. package/src/rsc/runtime-warnings.ts +42 -0
  133. package/src/rsc/server-action.ts +134 -58
  134. package/src/rsc/types.ts +8 -0
  135. package/src/search-params.ts +22 -10
  136. package/src/server/context.ts +53 -5
  137. package/src/server/fetchable-loader-store.ts +11 -6
  138. package/src/server/handle-store.ts +66 -9
  139. package/src/server/loader-registry.ts +11 -46
  140. package/src/server/request-context.ts +90 -9
  141. package/src/ssr/index.tsx +63 -27
  142. package/src/static-handler.ts +7 -0
  143. package/src/theme/ThemeProvider.tsx +6 -1
  144. package/src/theme/index.ts +1 -6
  145. package/src/theme/theme-context.ts +1 -28
  146. package/src/theme/theme-script.ts +2 -1
  147. package/src/types/cache-types.ts +5 -0
  148. package/src/types/error-types.ts +3 -0
  149. package/src/types/global-namespace.ts +9 -0
  150. package/src/types/handler-context.ts +35 -13
  151. package/src/types/loader-types.ts +7 -0
  152. package/src/types/route-entry.ts +28 -0
  153. package/src/urls/include-helper.ts +49 -8
  154. package/src/urls/index.ts +1 -0
  155. package/src/urls/path-helper-types.ts +30 -12
  156. package/src/urls/path-helper.ts +17 -2
  157. package/src/urls/pattern-types.ts +21 -1
  158. package/src/urls/response-types.ts +27 -2
  159. package/src/urls/type-extraction.ts +23 -15
  160. package/src/use-loader.tsx +12 -4
  161. package/src/vite/discovery/bundle-postprocess.ts +12 -7
  162. package/src/vite/discovery/discover-routers.ts +30 -18
  163. package/src/vite/discovery/prerender-collection.ts +24 -27
  164. package/src/vite/discovery/route-types-writer.ts +7 -7
  165. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  166. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  167. package/src/vite/plugins/use-cache-transform.ts +91 -3
  168. package/src/vite/rango.ts +3 -3
  169. package/src/vite/router-discovery.ts +99 -36
  170. package/src/vite/utils/prerender-utils.ts +21 -0
  171. package/src/vite/utils/shared-utils.ts +3 -1
  172. package/src/browser/request-controller.ts +0 -164
  173. package/src/href-context.ts +0 -33
  174. package/src/router.gen.ts +0 -6
  175. package/src/static-handler.gen.ts +0 -5
  176. package/src/urls.gen.ts +0 -8
  177. /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
@@ -23,6 +23,7 @@ import type { HandlerContext } from "./handler-context.js";
23
23
  import { createResponseErrorPayload } from "./response-error.js";
24
24
  import {
25
25
  createResponseWithMergedHeaders,
26
+ finalizeResponse,
26
27
  isCacheableStatus,
27
28
  buildRouteMiddlewareEntries,
28
29
  } from "./helpers.js";
@@ -70,25 +71,15 @@ export async function handleResponseRoute<TEnv>(
70
71
 
71
72
  // Build lightweight context for response handler
72
73
  const reqCtx = requireRequestContext();
74
+ const cleanUrl = stripInternalParams(url);
73
75
  const responseHandlerCtx = {
74
76
  request,
75
77
  params: preview.params || {},
76
78
  env,
77
- searchParams: url.searchParams,
78
- url,
79
+ searchParams: cleanUrl.searchParams,
80
+ url: cleanUrl,
79
81
  pathname: url.pathname,
80
- href: (name: string, hrefParams?: Record<string, string>) => {
81
- if (name.startsWith("/")) {
82
- if (!hrefParams) return name;
83
- return name.replace(/:([^/]+)/g, (_, key) => {
84
- const value = hrefParams[key];
85
- if (value === undefined)
86
- throw new Error(`Missing param "${key}" for path "${name}"`);
87
- return encodeURIComponent(value);
88
- });
89
- }
90
- return name;
91
- },
82
+ reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
92
83
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
93
84
  header: (name: string, value: string) => reqCtx.header(name, value),
94
85
  _responseType: preview.responseType,
@@ -103,10 +94,15 @@ export async function handleResponseRoute<TEnv>(
103
94
 
104
95
  // Re-wrap a handler-returned Response through createResponseWithMergedHeaders
105
96
  // so that stub headers (cookies, custom headers set via ctx.header()) are included.
97
+ // Use Headers (not Record<string, string>) to preserve duplicate entries like Set-Cookie.
106
98
  const rewrapResponse = (result: Response) => {
107
- const headers: Record<string, string> = {};
99
+ const headers = new Headers();
108
100
  result.headers.forEach((value, key) => {
109
- headers[key] = value;
101
+ if (key.toLowerCase() === "set-cookie") {
102
+ headers.append(key, value);
103
+ } else {
104
+ headers.set(key, value);
105
+ }
110
106
  });
111
107
  return createResponseWithMergedHeaders(result.body, {
112
108
  status: result.status,
@@ -231,12 +227,23 @@ export async function handleResponseRoute<TEnv>(
231
227
  }
232
228
 
233
229
  if (cacheScope?.enabled) {
230
+ // Evaluate condition — skip response cache when condition returns false
231
+ let conditionPassed = true;
232
+ if (cacheScope.config !== false && cacheScope.config.condition) {
233
+ try {
234
+ conditionPassed = !!cacheScope.config.condition(reqCtx);
235
+ } catch {
236
+ conditionPassed = false;
237
+ }
238
+ }
239
+
234
240
  const store = cacheScope.getStore() ?? reqCtx._cacheStore;
235
- if (store?.getResponse && store?.putResponse) {
241
+ if (conditionPassed && store?.getResponse && store?.putResponse) {
236
242
  // Build cache key with response:{type}: prefix to avoid collision
237
243
  // with segment keys and differentiate between response types.
238
- // Include url.search so query-driven responses cache separately.
239
- let cacheKey = `response:${preview.responseType}:${url.pathname}${url.search}`;
244
+ // Include host and url.search so query-driven and multi-host
245
+ // responses cache separately.
246
+ let cacheKey = `response:${preview.responseType}:${url.host}${url.pathname}${url.search}`;
240
247
 
241
248
  // Priority 1: Route-level key function (full override)
242
249
  if (cacheScope.config !== false && cacheScope.config.key) {
@@ -286,11 +293,14 @@ export async function handleResponseRoute<TEnv>(
286
293
  // Stale hit (SWR) - return cached, revalidate in background
287
294
  reqCtx.waitUntil(async () => {
288
295
  try {
289
- const fresh = await executeHandler();
296
+ // finalizeResponse drains any onResponse callbacks registered
297
+ // during middleware execution (e.g. middleware short-circuit)
298
+ // that createResponseWithMergedHeaders didn't reach.
299
+ const fresh = finalizeResponse(await executeHandler());
290
300
  if (isCacheableStatus(fresh.status)) {
291
301
  await store.putResponse!(
292
302
  cacheKey,
293
- fresh,
303
+ fresh.clone(),
294
304
  cacheScope!.ttl,
295
305
  cacheScope!.swr,
296
306
  );
@@ -307,10 +317,11 @@ export async function handleResponseRoute<TEnv>(
307
317
  }
308
318
 
309
319
  // Cache miss - execute handler and cache the result.
310
- // createResponseWithMergedHeaders inside the handler applies
311
- // any callbacks registered during execution, so the response
312
- // (and its clone) already include those transforms.
313
- const response = await executeHandler();
320
+ // createResponseWithMergedHeaders inside the handler drains callbacks
321
+ // registered during handler execution. finalizeResponse catches any
322
+ // remaining callbacks (e.g. from middleware short-circuit where the
323
+ // handler never ran) so the cached artifact includes all transforms.
324
+ const response = finalizeResponse(await executeHandler());
314
325
 
315
326
  if (isCacheableStatus(response.status)) {
316
327
  reqCtx.waitUntil(async () => {
@@ -332,5 +343,5 @@ export async function handleResponseRoute<TEnv>(
332
343
  }
333
344
  }
334
345
 
335
- return executeHandler();
346
+ return executeHandler().then(finalizeResponse);
336
347
  }
@@ -111,6 +111,7 @@ export async function handleRscRendering<TEnv>(
111
111
  const nonLoaderSegments = match.segments.filter(
112
112
  (s) => s.type !== "loader",
113
113
  );
114
+ handleStore.seal();
114
115
  await handleStore.settled;
115
116
  const { serializeSegments } = await import("../cache/segment-codec.js");
116
117
  const serializedSegments = await serializeSegments(nonLoaderSegments);
@@ -213,13 +214,19 @@ export async function handleRscRendering<TEnv>(
213
214
  }
214
215
 
215
216
  // Delegate to SSR for HTML response
216
- const ssrModuleStart = performance.now();
217
- const ssrModule = await ctx.loadSSRModule();
218
- const ssrModuleDur = performance.now() - ssrModuleStart;
219
- timingParts.push(`ssr-module-load;dur=${ssrModuleDur.toFixed(2)}`);
217
+ const ssrSetupStart = performance.now();
218
+ const [ssrModule, streamMode] = await Promise.all([
219
+ ctx.loadSSRModule(),
220
+ ctx.resolveStreamMode(request, env, url),
221
+ ]);
222
+ const ssrSetupDur = performance.now() - ssrSetupStart;
223
+ timingParts.push(`ssr-setup;dur=${ssrSetupDur.toFixed(2)}`);
220
224
 
221
225
  const ssrRenderStart = performance.now();
222
- const htmlStream = await ssrModule.renderHTML(rscStream, { nonce });
226
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
227
+ nonce,
228
+ streamMode,
229
+ });
223
230
  const ssrRenderDur = performance.now() - ssrRenderStart;
224
231
  timingParts.push(`ssr-render-html;dur=${ssrRenderDur.toFixed(2)}`);
225
232
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Runtime guardrail warnings (dev-only).
3
+ *
4
+ * W3: PE action redirect / Response handling.
5
+ */
6
+
7
+ import {
8
+ createResponseWithMergedHeaders,
9
+ carryOverRedirectHeaders,
10
+ } from "./helpers.js";
11
+
12
+ // W3 -----------------------------------------------------------------------
13
+
14
+ /**
15
+ * Extract a redirect Response from a thrown or returned value.
16
+ * Returns a redirect Response to send to the client, or null if the value
17
+ * is not a redirect Response.
18
+ */
19
+ export function extractRedirectResponse(value: unknown): Response | null {
20
+ if (!(value instanceof Response)) return null;
21
+ const location = value.headers.get("Location");
22
+ if (value.status >= 300 && value.status < 400 && location) {
23
+ const redirect = createResponseWithMergedHeaders(null, {
24
+ status: value.status,
25
+ headers: { Location: location },
26
+ });
27
+ carryOverRedirectHeaders(value, redirect);
28
+ return redirect;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Warn when a non-redirect Response is returned from an action during PE.
35
+ */
36
+ export function warnNonRedirectPeResponse(): void {
37
+ console.warn(
38
+ `[rango] Server action returned a non-redirect Response during ` +
39
+ `progressive enhancement (no-JS) request. The Response will be ` +
40
+ `ignored — the page will re-render at the current URL instead.`,
41
+ );
42
+ }
@@ -1,9 +1,18 @@
1
1
  /**
2
2
  * Server Action Handler
3
3
  *
4
- * Handles server action execution and post-action revalidation.
5
- * Decodes action arguments, executes the action, handles redirects
6
- * and error boundaries, then revalidates affected segments.
4
+ * Handles server action execution and post-action revalidation as two
5
+ * separate phases:
6
+ *
7
+ * 1. executeServerAction — decodes args, runs the action, handles redirects
8
+ * and error boundaries. Returns either a final Response (redirect/error)
9
+ * or an ActionContinuation for the revalidation phase.
10
+ *
11
+ * 2. revalidateAfterAction — takes the continuation, matches affected
12
+ * segments, builds the RSC payload, and returns the Flight response.
13
+ *
14
+ * The handler (handler.ts) runs the action BEFORE route middleware, then
15
+ * wraps revalidation inside route middleware — identical to a normal render.
7
16
  */
8
17
 
9
18
  import {
@@ -17,6 +26,7 @@ import {
17
26
  hasBodyContent,
18
27
  createResponseWithMergedHeaders,
19
28
  createSimpleRedirectResponse,
29
+ carryOverRedirectHeaders,
20
30
  } from "./helpers.js";
21
31
  import type { HandlerContext } from "./handler-context.js";
22
32
 
@@ -32,14 +42,40 @@ function attachLocationState(payload: RscPayload): void {
32
42
  }
33
43
  }
34
44
 
35
- export async function handleServerAction<TEnv>(
45
+ /**
46
+ * Data flowing from action execution to the revalidation phase.
47
+ * When the action completes without redirect/error-boundary, the handler
48
+ * passes this to route middleware → revalidateAfterAction.
49
+ */
50
+ export interface ActionContinuation {
51
+ returnValue: { ok: boolean; data: unknown };
52
+ actionStatus: number;
53
+ temporaryReferences: ReturnType<
54
+ HandlerContext["createTemporaryReferenceSet"]
55
+ >;
56
+ actionContext: {
57
+ actionId: string;
58
+ actionUrl: URL;
59
+ actionResult: unknown;
60
+ formData?: FormData;
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Phase 1: Execute the server action.
66
+ *
67
+ * Decodes arguments, runs the action, handles redirects and error
68
+ * boundaries. Returns a final Response (redirect, error boundary render)
69
+ * or an ActionContinuation for the revalidation phase.
70
+ */
71
+ export async function executeServerAction<TEnv>(
36
72
  ctx: HandlerContext<TEnv>,
37
73
  request: Request,
38
74
  env: TEnv,
39
75
  url: URL,
40
76
  actionId: string,
41
77
  handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
42
- ): Promise<Response> {
78
+ ): Promise<Response | ActionContinuation> {
43
79
  const temporaryReferences = ctx.createTemporaryReferenceSet();
44
80
 
45
81
  // Decode action arguments from request body
@@ -82,15 +118,17 @@ export async function handleServerAction<TEnv>(
82
118
  const isRedirect = data.status >= 300 && data.status < 400 && redirectUrl;
83
119
  if (isRedirect) {
84
120
  const locationState = getLocationState();
121
+ let redirect: Response;
85
122
  if (locationState) {
86
- // Redirect with state: needs Flight payload to carry state
87
- return ctx.createRedirectFlightResponse(
123
+ redirect = ctx.createRedirectFlightResponse(
88
124
  redirectUrl,
89
125
  resolveLocationStateEntries(locationState),
90
126
  );
127
+ } else {
128
+ redirect = createSimpleRedirectResponse(redirectUrl);
91
129
  }
92
- // Simple redirect: short-circuit with a header, no RSC serialization
93
- return createSimpleRedirectResponse(redirectUrl);
130
+ carryOverRedirectHeaders(data, redirect);
131
+ return redirect;
94
132
  }
95
133
  }
96
134
 
@@ -103,28 +141,58 @@ export async function handleServerAction<TEnv>(
103
141
  error.status >= 300 && error.status < 400 && redirectUrl;
104
142
  if (isRedirect) {
105
143
  const locationState = getLocationState();
144
+ let redirect: Response;
106
145
  if (locationState) {
107
- return ctx.createRedirectFlightResponse(
146
+ redirect = ctx.createRedirectFlightResponse(
108
147
  redirectUrl,
109
148
  resolveLocationStateEntries(locationState),
110
149
  );
150
+ } else {
151
+ redirect = createSimpleRedirectResponse(redirectUrl);
111
152
  }
112
- return createSimpleRedirectResponse(redirectUrl);
153
+ carryOverRedirectHeaders(error, redirect);
154
+ return redirect;
155
+ }
156
+
157
+ // Non-redirect Response thrown from action — this will be treated
158
+ // as a regular error and routed to the error boundary. Warn in dev
159
+ // since the intent is likely a redirect with a missing Location header.
160
+ if (process.env.NODE_ENV !== "production") {
161
+ console.warn(
162
+ `[@rangojs/router] Server action "${actionId}" threw a Response ` +
163
+ `(status ${error.status}) that is not a redirect. ` +
164
+ `Non-redirect Responses are treated as errors. ` +
165
+ `Use \`throw redirect('/path')\` for redirects.`,
166
+ );
113
167
  }
114
168
  }
115
169
 
116
170
  returnValue = { ok: false, data: error };
117
171
  actionStatus = 500;
118
172
 
119
- // Try to render error boundary
120
- const errorResult = await ctx.router.matchError(
121
- request,
122
- { env },
123
- error,
124
- "route",
125
- );
173
+ // Try to render error boundary.
174
+ // Report the action error first so it is not lost if matchError throws.
175
+ let errorResult;
176
+ try {
177
+ errorResult = await ctx.router.matchError(
178
+ request,
179
+ { env },
180
+ error,
181
+ "route",
182
+ );
183
+ } catch (matchErr) {
184
+ // matchError failed — report the original action error as unhandled,
185
+ // then let the matchError failure propagate.
186
+ ctx.callOnError(error, "action", {
187
+ request,
188
+ url,
189
+ env,
190
+ actionId,
191
+ handledByBoundary: false,
192
+ });
193
+ throw matchErr;
194
+ }
126
195
 
127
- // Report the action error (handledByBoundary indicates if error boundary will render)
128
196
  ctx.callOnError(error, "action", {
129
197
  request,
130
198
  url,
@@ -165,17 +233,47 @@ export async function handleServerAction<TEnv>(
165
233
  }
166
234
  }
167
235
 
168
- // Revalidate after action
236
+ // Build continuation for the revalidation phase
169
237
  const resolvedActionId =
170
238
  (loadedAction as { $id?: string; $$id?: string } | undefined)?.$id ??
171
239
  (loadedAction as { $$id?: string } | undefined)?.$$id ??
172
240
  actionId;
173
- const actionContext = {
174
- actionId: resolvedActionId,
175
- actionUrl: new URL(request.url),
176
- actionResult: returnValue.data,
177
- formData: actionFormData,
241
+
242
+ return {
243
+ returnValue,
244
+ actionStatus,
245
+ temporaryReferences,
246
+ actionContext: {
247
+ actionId: resolvedActionId,
248
+ actionUrl: new URL(request.url),
249
+ actionResult: returnValue.data,
250
+ formData: actionFormData,
251
+ },
178
252
  };
253
+ }
254
+
255
+ /**
256
+ * Phase 2: Revalidate after action.
257
+ *
258
+ * Matches affected segments, builds the RSC payload, and returns the
259
+ * Flight response. Called inside route middleware (same as a normal render).
260
+ *
261
+ * Invariant: the response payload MUST have isPartial: true. The client
262
+ * (server-action-bridge) rejects non-partial payloads because partial
263
+ * reconciliation requires matched/diff semantics that full renders don't
264
+ * provide. Redirects are the only non-partial outcome and are handled via
265
+ * X-RSC-Redirect headers before Flight deserialization.
266
+ */
267
+ export async function revalidateAfterAction<TEnv>(
268
+ ctx: HandlerContext<TEnv>,
269
+ request: Request,
270
+ env: TEnv,
271
+ url: URL,
272
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
273
+ continuation: ActionContinuation,
274
+ ): Promise<Response> {
275
+ const { returnValue, actionStatus, temporaryReferences, actionContext } =
276
+ continuation;
179
277
 
180
278
  const matchResult = await ctx.router.matchPartial(
181
279
  request,
@@ -184,7 +282,8 @@ export async function handleServerAction<TEnv>(
184
282
  );
185
283
 
186
284
  if (!matchResult) {
187
- // Fall back to full render
285
+ // matchPartial returns null when the route is a redirect or the request
286
+ // is missing required headers (previousUrl). Check for redirect first.
188
287
  const fullMatch = await ctx.router.match(request, { env });
189
288
  setRequestContextParams(fullMatch.params, fullMatch.routeName);
190
289
 
@@ -195,38 +294,15 @@ export async function handleServerAction<TEnv>(
195
294
  return createSimpleRedirectResponse(fullMatch.redirect);
196
295
  }
197
296
 
198
- const serverTiming = fullMatch.serverTiming;
199
-
200
- const payload: RscPayload = {
201
- metadata: {
202
- pathname: url.pathname,
203
- segments: fullMatch.segments,
204
- matched: fullMatch.matched,
205
- diff: fullMatch.diff,
206
- rootLayout: ctx.router.rootLayout,
207
- handles: handleStore.stream(),
208
- version: ctx.version,
209
- },
210
- returnValue,
211
- };
212
-
213
- attachLocationState(payload);
214
-
215
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
216
- temporaryReferences,
217
- });
218
-
219
- const headers: Record<string, string> = {
220
- "content-type": "text/x-component;charset=utf-8",
221
- };
222
- if (serverTiming) {
223
- headers["Server-Timing"] = serverTiming;
224
- }
225
-
226
- return createResponseWithMergedHeaders(rscStream, {
227
- status: actionStatus,
228
- headers,
229
- });
297
+ // Non-redirect: this branch is only reachable when the action request
298
+ // is missing the X-RSC-Router-Client-Path header (defensive). The
299
+ // client requires isPartial for action responses, so producing a full
300
+ // payload here would be rejected. Return 500 instead.
301
+ throw new Error(
302
+ `[RSC] matchPartial returned null for a non-redirect route ` +
303
+ `during action revalidation (${url.pathname}). This indicates ` +
304
+ `a malformed action request (missing X-RSC-Router-Client-Path header).`,
305
+ );
230
306
  }
231
307
 
232
308
  // Return updated segments
package/src/rsc/types.ts CHANGED
@@ -114,6 +114,14 @@ export interface SSRRenderOptions {
114
114
  * Nonce for Content Security Policy (CSP)
115
115
  */
116
116
  nonce?: string;
117
+
118
+ /**
119
+ * SSR stream mode.
120
+ *
121
+ * - `"stream"` (default) — start flushing HTML immediately.
122
+ * - `"allReady"` — await `stream.allReady` before returning.
123
+ */
124
+ streamMode?: import("../router/router-options.js").SSRStreamMode;
117
125
  }
118
126
 
119
127
  /**
@@ -127,20 +127,32 @@ type ExtractRouteParamsFromMap<TRouteMap, TName> = TName extends keyof TRouteMap
127
127
  : {}
128
128
  : {};
129
129
 
130
+ /** Parse "a|b|c" into "a" | "b" | "c" */
131
+ type ParseConstraint<T extends string> =
132
+ T extends `${infer First}|${infer Rest}` ? First | ParseConstraint<Rest> : T;
133
+
130
134
  /** Minimal inline param extraction (avoids importing from types.ts to prevent circular deps). */
131
135
  type ExtractParamsFromPattern<T extends string> =
132
136
  T extends `${string}:${infer Param}/${infer Rest}`
133
- ? Param extends `${infer Name}?`
134
- ? { [K in Name]?: string } & ExtractParamsFromPattern<`/${Rest}`>
135
- : Param extends `${infer Name}(${string})`
136
- ? { [K in Name]: string } & ExtractParamsFromPattern<`/${Rest}`>
137
- : { [K in Param]: string } & ExtractParamsFromPattern<`/${Rest}`>
137
+ ? Param extends `${infer Name}(${infer C})?`
138
+ ? {
139
+ [K in Name]?: ParseConstraint<C>;
140
+ } & ExtractParamsFromPattern<`/${Rest}`>
141
+ : Param extends `${infer Name}(${infer C})`
142
+ ? {
143
+ [K in Name]: ParseConstraint<C>;
144
+ } & ExtractParamsFromPattern<`/${Rest}`>
145
+ : Param extends `${infer Name}?`
146
+ ? { [K in Name]?: string } & ExtractParamsFromPattern<`/${Rest}`>
147
+ : { [K in Param]: string } & ExtractParamsFromPattern<`/${Rest}`>
138
148
  : T extends `${string}:${infer Param}`
139
- ? Param extends `${infer Name}?`
140
- ? { [K in Name]?: string }
141
- : Param extends `${infer Name}(${string})`
142
- ? { [K in Name]: string }
143
- : { [K in Param]: string }
149
+ ? Param extends `${infer Name}(${infer C})?`
150
+ ? { [K in Name]?: ParseConstraint<C> }
151
+ : Param extends `${infer Name}(${infer C})`
152
+ ? { [K in Name]: ParseConstraint<C> }
153
+ : Param extends `${infer Name}?`
154
+ ? { [K in Name]?: string }
155
+ : { [K in Param]: string }
144
156
  : {};
145
157
 
146
158
  // ============================================================================
@@ -11,6 +11,7 @@ import type {
11
11
  TransitionConfig,
12
12
  } from "../types";
13
13
  import { invariant } from "../errors";
14
+ import type { DefaultRouteName } from "../types/global-namespace.js";
14
15
 
15
16
  // ============================================================================
16
17
  // Performance Metrics Types
@@ -120,6 +121,8 @@ export type InterceptSelectorContext<TEnv = any> = {
120
121
  request: Request; // The HTTP request object
121
122
  env: TEnv; // Platform bindings (Cloudflare env, etc.)
122
123
  segments: InterceptSegmentsState; // Client's current segments (where navigating FROM)
124
+ fromRouteName?: DefaultRouteName; // Named route being navigated away from (undefined for unnamed routes)
125
+ toRouteName?: DefaultRouteName; // Named route being navigated to (undefined for unnamed routes)
123
126
  };
124
127
 
125
128
  /**
@@ -254,10 +257,18 @@ interface HelperContext {
254
257
  urlPrefix?: string;
255
258
  /** Name prefix from include() - applied to all named routes */
256
259
  namePrefix?: string;
260
+ /** True when this scope is at root level (no named include boundary above).
261
+ * Routes at root scope allow dot-local reverse to fall back to bare names. */
262
+ rootScoped?: boolean;
257
263
  /** Run helper for cleaner middleware code */
258
264
  run?: <T>(fn: () => T | Promise<T>) => T | Promise<T>;
259
265
  /** Tracked includes for build-time manifest generation */
260
266
  trackedIncludes?: TrackedInclude[];
267
+ /** Cache profiles for DSL-time cache("profileName") resolution */
268
+ cacheProfiles?: Record<
269
+ string,
270
+ import("../cache/profile-registry.js").CacheProfile
271
+ >;
261
272
  }
262
273
  // Use a global symbol key so the AsyncLocalStorage instance survives HMR
263
274
  // module re-evaluation. Without this, Vite's RSC module runner may create
@@ -399,7 +410,9 @@ export const getContext = (): {
399
410
  searchSchemas: store.searchSchemas,
400
411
  urlPrefix: store.urlPrefix,
401
412
  namePrefix: store.namePrefix,
413
+ rootScoped: store.rootScoped,
402
414
  trackedIncludes: store.trackedIncludes,
415
+ cacheProfiles: store.cacheProfiles,
403
416
  },
404
417
  callback,
405
418
  );
@@ -436,7 +449,9 @@ export const getContext = (): {
436
449
  searchSchemas,
437
450
  urlPrefix: store?.urlPrefix,
438
451
  namePrefix: store?.namePrefix,
452
+ rootScoped: store?.rootScoped,
439
453
  trackedIncludes: store?.trackedIncludes,
454
+ cacheProfiles: store?.cacheProfiles,
440
455
  },
441
456
  callback,
442
457
  );
@@ -469,17 +484,41 @@ export function runWithPrefixes<T>(
469
484
  } else {
470
485
  combinedUrlPrefix = urlPrefix;
471
486
  }
472
- const combinedNamePrefix = namePrefix
473
- ? store.namePrefix
474
- ? `${store.namePrefix}.${namePrefix}`
475
- : namePrefix
476
- : store.namePrefix;
487
+ const combinedNamePrefix =
488
+ namePrefix !== undefined
489
+ ? namePrefix === ""
490
+ ? store.namePrefix
491
+ : store.namePrefix
492
+ ? `${store.namePrefix}.${namePrefix}`
493
+ : namePrefix
494
+ : store.namePrefix;
495
+
496
+ // Track root scope for dot-local reverse resolution.
497
+ //
498
+ // The flag answers: "can this route reach bare names at root scope?"
499
+ // It propagates through the include chain:
500
+ //
501
+ // { name: "" } — transparent: inherit parent, default true
502
+ // { name: "foo" } — inherit parent if already set, else create boundary (false)
503
+ // no name — inherit parent unchanged
504
+ //
505
+ // This means { name: "" } + nested { name: "sub" } keeps rootScoped=true
506
+ // (the outer transparent include establishes root access, and the inner
507
+ // named include inherits it). But a direct { name: "sub" } at root gets
508
+ // rootScoped=false (no prior root-access grant, so it creates a boundary).
509
+ const combinedRootScoped =
510
+ namePrefix === ""
511
+ ? (store.rootScoped ?? true)
512
+ : namePrefix !== undefined
513
+ ? (store.rootScoped ?? false)
514
+ : store.rootScoped;
477
515
 
478
516
  return RSCRouterContext.run(
479
517
  {
480
518
  ...store,
481
519
  urlPrefix: combinedUrlPrefix,
482
520
  namePrefix: combinedNamePrefix,
521
+ rootScoped: combinedRootScoped,
483
522
  },
484
523
  callback,
485
524
  );
@@ -501,6 +540,15 @@ export function getNamePrefix(): string | undefined {
501
540
  return store?.namePrefix;
502
541
  }
503
542
 
543
+ /**
544
+ * Get whether the current scope is at root level (no named include boundary above).
545
+ * Returns true at root or inside { name: "" } includes, false inside named includes.
546
+ */
547
+ export function getRootScoped(): boolean {
548
+ const store = RSCRouterContext.getStore();
549
+ return store?.rootScoped ?? true;
550
+ }
551
+
504
552
  // Export HelperContext type for use in other modules
505
553
  export type { HelperContext };
506
554