@rangojs/router 0.24.3 → 0.25.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 (274) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/rango.js +363 -39
  3. package/dist/testing/vitest.js +37 -4
  4. package/dist/types/browser/event-controller.d.ts +24 -5
  5. package/dist/types/browser/intercept-utils.d.ts +5 -2
  6. package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
  7. package/dist/types/browser/navigation-transaction.d.ts +5 -5
  8. package/dist/types/browser/partial-update.d.ts +21 -13
  9. package/dist/types/browser/prefetch/cache.d.ts +8 -0
  10. package/dist/types/browser/rango-marker.d.ts +7 -0
  11. package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
  12. package/dist/types/browser/react/use-handle.d.ts +20 -0
  13. package/dist/types/browser/react/use-href.d.ts +6 -0
  14. package/dist/types/browser/react/use-router.d.ts +6 -1
  15. package/dist/types/browser/response-adapter.d.ts +5 -3
  16. package/dist/types/browser/scroll-restoration.d.ts +9 -3
  17. package/dist/types/browser/segment-reconciler.d.ts +10 -13
  18. package/dist/types/browser/types.d.ts +38 -8
  19. package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
  20. package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
  21. package/dist/types/cache/types.d.ts +8 -0
  22. package/dist/types/client-urls/client-root.d.ts +4 -1
  23. package/dist/types/client-urls/client-urls.d.ts +28 -2
  24. package/dist/types/client-urls/navigation.d.ts +6 -8
  25. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  26. package/dist/types/client-urls/server-projection.d.ts +20 -5
  27. package/dist/types/client-urls/types.d.ts +35 -17
  28. package/dist/types/client.d.ts +2 -2
  29. package/dist/types/client.rsc.d.ts +2 -2
  30. package/dist/types/decode-loader-results.d.ts +28 -7
  31. package/dist/types/href-client.d.ts +3 -1
  32. package/dist/types/index.rsc.d.ts +1 -1
  33. package/dist/types/loader-redirect.d.ts +2 -3
  34. package/dist/types/outlet-context.d.ts +7 -6
  35. package/dist/types/prefetch-markers.d.ts +14 -5
  36. package/dist/types/route-content-wrapper.d.ts +17 -14
  37. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  38. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  39. package/dist/types/route-definition/rewrite.d.ts +48 -5
  40. package/dist/types/route-types.d.ts +2 -2
  41. package/dist/types/router/handler-context.d.ts +14 -2
  42. package/dist/types/router/intercept-resolution.d.ts +19 -46
  43. package/dist/types/router/lazy-includes.d.ts +7 -0
  44. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  45. package/dist/types/router/middleware.d.ts +0 -16
  46. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  47. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  48. package/dist/types/router/revalidation.d.ts +8 -1
  49. package/dist/types/router/router-context.d.ts +4 -16
  50. package/dist/types/router/router-interfaces.d.ts +0 -6
  51. package/dist/types/router/router-options.d.ts +4 -7
  52. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  53. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  54. package/dist/types/router/segment-resolution.d.ts +1 -1
  55. package/dist/types/router/segment-wrappers.d.ts +3 -5
  56. package/dist/types/router/substitute-pattern-params.d.ts +19 -2
  57. package/dist/types/router/tracing.d.ts +2 -2
  58. package/dist/types/rsc/full-payload.d.ts +14 -2
  59. package/dist/types/rsc/helpers.d.ts +8 -0
  60. package/dist/types/rsc/manifest-init.d.ts +20 -0
  61. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  62. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  63. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  64. package/dist/types/rsc/server-action.d.ts +6 -0
  65. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  66. package/dist/types/rsc/shell-capture.d.ts +7 -0
  67. package/dist/types/rsc/types.d.ts +5 -0
  68. package/dist/types/segment-system.d.ts +15 -0
  69. package/dist/types/server/context.d.ts +64 -4
  70. package/dist/types/server/request-context.d.ts +41 -4
  71. package/dist/types/server/request-headers.d.ts +12 -0
  72. package/dist/types/server/rewrite-trail.d.ts +8 -1
  73. package/dist/types/testing/e2e/index.d.ts +1 -1
  74. package/dist/types/testing/flight.entry.d.ts +1 -1
  75. package/dist/types/testing/index.d.ts +1 -1
  76. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  77. package/dist/types/testing/render-route.d.ts +21 -11
  78. package/dist/types/testing/run-loader.d.ts +7 -0
  79. package/dist/types/testing/run-middleware.d.ts +7 -0
  80. package/dist/types/testing/run-revalidate.d.ts +5 -4
  81. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  82. package/dist/types/testing/shell-status.d.ts +17 -0
  83. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  84. package/dist/types/testing/vitest.d.ts +10 -1
  85. package/dist/types/thenable-status.d.ts +0 -6
  86. package/dist/types/types/handler-context.d.ts +12 -1
  87. package/dist/types/types/loader-types.d.ts +4 -3
  88. package/dist/types/types/route-entry.d.ts +2 -1
  89. package/dist/types/urls/path-helper-types.d.ts +4 -2
  90. package/dist/types/urls/path-helper.d.ts +5 -0
  91. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  92. package/dist/types/vite/discovery/state.d.ts +20 -0
  93. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  94. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  95. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  96. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  97. package/dist/vite/index.js +6816 -6547
  98. package/package.json +1 -1
  99. package/skills/app-structure/SKILL.md +255 -0
  100. package/skills/app-structure/content-site.md +333 -0
  101. package/skills/app-structure/e-commerce.md +435 -0
  102. package/skills/app-structure/large-app.md +885 -0
  103. package/skills/app-structure/multi-app.md +280 -0
  104. package/skills/breadcrumbs/SKILL.md +6 -1
  105. package/skills/caching/SKILL.md +3 -1
  106. package/skills/catalog.json +12 -0
  107. package/skills/client-urls/SKILL.md +195 -32
  108. package/skills/composability/SKILL.md +10 -8
  109. package/skills/handler-use/SKILL.md +48 -9
  110. package/skills/hooks/handle-and-actions.md +104 -0
  111. package/skills/hooks/navigation.md +24 -10
  112. package/skills/hooks/urls.md +1 -1
  113. package/skills/i18n/SKILL.md +2 -1
  114. package/skills/intercept/SKILL.md +60 -29
  115. package/skills/layout/SKILL.md +6 -3
  116. package/skills/links/SKILL.md +151 -17
  117. package/skills/loader/SKILL.md +32 -10
  118. package/skills/middleware/SKILL.md +111 -15
  119. package/skills/observability/SKILL.md +11 -0
  120. package/skills/parallel/SKILL.md +5 -5
  121. package/skills/ppr/SKILL.md +43 -8
  122. package/skills/prerender/SKILL.md +1 -2
  123. package/skills/rango/SKILL.md +3 -0
  124. package/skills/response-routes/SKILL.md +4 -1
  125. package/skills/scripts/SKILL.md +12 -1
  126. package/skills/server-actions/SKILL.md +60 -20
  127. package/skills/testing/SKILL.md +1 -0
  128. package/skills/testing/client-components.md +13 -10
  129. package/skills/testing/loader.md +25 -24
  130. package/skills/testing/middleware.md +11 -11
  131. package/skills/testing/response-routes.md +4 -1
  132. package/skills/testing/serve-shell-request.md +85 -21
  133. package/skills/ux-architecture/SKILL.md +980 -0
  134. package/skills/view-transitions/SKILL.md +43 -10
  135. package/src/__augment-tests__/augmented.check.ts +28 -0
  136. package/src/browser/event-controller.ts +44 -18
  137. package/src/browser/intercept-utils.ts +3 -2
  138. package/src/browser/merge-segment-loaders.ts +0 -85
  139. package/src/browser/navigation-bridge.ts +119 -98
  140. package/src/browser/navigation-client.ts +20 -15
  141. package/src/browser/navigation-store.ts +4 -2
  142. package/src/browser/navigation-transaction.ts +9 -8
  143. package/src/browser/partial-update.ts +90 -32
  144. package/src/browser/prefetch/cache.ts +8 -0
  145. package/src/browser/prefetch/fetch.ts +13 -3
  146. package/src/browser/rango-marker.ts +13 -0
  147. package/src/browser/react/NavigationProvider.tsx +25 -7
  148. package/src/browser/react/filter-segment-order.ts +13 -0
  149. package/src/browser/react/use-action.ts +9 -15
  150. package/src/browser/react/use-handle.ts +63 -71
  151. package/src/browser/react/use-href.tsx +24 -5
  152. package/src/browser/react/use-link-status.ts +1 -1
  153. package/src/browser/react/use-navigation.ts +30 -40
  154. package/src/browser/react/use-reverse.ts +4 -17
  155. package/src/browser/react/use-router.ts +37 -6
  156. package/src/browser/react/use-segments.ts +8 -12
  157. package/src/browser/response-adapter.ts +11 -5
  158. package/src/browser/rsc-router.tsx +3 -1
  159. package/src/browser/scroll-restoration.ts +34 -20
  160. package/src/browser/segment-reconciler.ts +11 -29
  161. package/src/browser/server-action-bridge.ts +52 -16
  162. package/src/browser/types.ts +40 -8
  163. package/src/build/generate-manifest.ts +12 -1
  164. package/src/build/route-types/codegen.ts +6 -2
  165. package/src/build/route-types/per-module-writer.ts +94 -61
  166. package/src/cache/cache-runtime.ts +9 -0
  167. package/src/cache/cache-scope.ts +2 -1
  168. package/src/cache/cf/cf-cache-store.ts +2 -0
  169. package/src/cache/cf/cf-shell-frame.ts +3 -0
  170. package/src/cache/types.ts +8 -0
  171. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  172. package/src/client-urls/client-root.tsx +10 -5
  173. package/src/client-urls/client-urls.ts +246 -166
  174. package/src/client-urls/navigation.ts +45 -38
  175. package/src/client-urls/revalidate-registry.ts +12 -3
  176. package/src/client-urls/server-projection.ts +286 -75
  177. package/src/client-urls/types.ts +40 -16
  178. package/src/client.rsc.tsx +11 -1
  179. package/src/client.tsx +9 -1
  180. package/src/decode-loader-results.ts +64 -31
  181. package/src/href-client.ts +3 -1
  182. package/src/index.rsc.ts +1 -0
  183. package/src/index.ts +12 -4
  184. package/src/loader-redirect.tsx +2 -3
  185. package/src/outlet-context.ts +7 -6
  186. package/src/prefetch-markers.ts +15 -5
  187. package/src/reverse.ts +3 -3
  188. package/src/route-content-wrapper.tsx +19 -17
  189. package/src/route-definition/dsl-helpers.ts +27 -7
  190. package/src/route-definition/helpers-types.ts +7 -2
  191. package/src/route-definition/resolve-handler-use.ts +23 -10
  192. package/src/route-definition/rewrite.ts +86 -19
  193. package/src/route-types.ts +1 -2
  194. package/src/router/find-match.ts +37 -1
  195. package/src/router/handler-context.ts +85 -40
  196. package/src/router/intercept-resolution.ts +161 -307
  197. package/src/router/lazy-includes.ts +43 -27
  198. package/src/router/loader-resolution.ts +21 -1
  199. package/src/router/manifest.ts +6 -8
  200. package/src/router/match-api.ts +11 -1
  201. package/src/router/match-middleware/background-revalidation.ts +2 -4
  202. package/src/router/match-middleware/cache-lookup.ts +16 -20
  203. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  204. package/src/router/match-result.ts +31 -2
  205. package/src/router/middleware.ts +6 -147
  206. package/src/router/navigation-snapshot.ts +38 -2
  207. package/src/router/prefetch-cache-ttl.ts +10 -18
  208. package/src/router/prerender-match.ts +2 -1
  209. package/src/router/revalidation.ts +24 -3
  210. package/src/router/router-context.ts +5 -18
  211. package/src/router/router-interfaces.ts +0 -7
  212. package/src/router/router-options.ts +4 -7
  213. package/src/router/segment-resolution/fresh.ts +6 -2
  214. package/src/router/segment-resolution/helpers.ts +7 -1
  215. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  216. package/src/router/segment-resolution/revalidation.ts +75 -35
  217. package/src/router/segment-resolution.ts +0 -1
  218. package/src/router/segment-wrappers.ts +8 -13
  219. package/src/router/substitute-pattern-params.ts +37 -2
  220. package/src/router/tracing.ts +2 -2
  221. package/src/router.ts +2 -9
  222. package/src/rsc/full-payload.ts +26 -4
  223. package/src/rsc/handler.ts +83 -41
  224. package/src/rsc/helpers.ts +13 -0
  225. package/src/rsc/manifest-init.ts +56 -3
  226. package/src/rsc/prefetch-render-error.ts +38 -0
  227. package/src/rsc/progressive-enhancement.ts +161 -211
  228. package/src/rsc/rewrite-passes.ts +56 -9
  229. package/src/rsc/rsc-rendering.ts +79 -23
  230. package/src/rsc/server-action.ts +25 -2
  231. package/src/rsc/shell-capture-constants.ts +22 -0
  232. package/src/rsc/shell-capture.ts +11 -0
  233. package/src/rsc/types.ts +5 -0
  234. package/src/segment-system.tsx +125 -119
  235. package/src/server/context.ts +96 -5
  236. package/src/server/handle-store.ts +3 -2
  237. package/src/server/request-context.ts +42 -3
  238. package/src/server/request-headers.ts +32 -0
  239. package/src/server/rewrite-trail.ts +55 -9
  240. package/src/ssr/ssr-root.tsx +6 -10
  241. package/src/testing/dispatch.ts +4 -5
  242. package/src/testing/e2e/index.ts +1 -0
  243. package/src/testing/flight.entry.ts +1 -0
  244. package/src/testing/index.ts +1 -0
  245. package/src/testing/internal/client-urls-projection.ts +31 -11
  246. package/src/testing/render-route.tsx +83 -40
  247. package/src/testing/run-loader.ts +26 -2
  248. package/src/testing/run-middleware.ts +10 -0
  249. package/src/testing/run-revalidate.ts +13 -6
  250. package/src/testing/serve-shell-request.ts +77 -4
  251. package/src/testing/shell-status.ts +40 -0
  252. package/src/testing/vitest-stubs/version.ts +1 -0
  253. package/src/testing/vitest.ts +74 -7
  254. package/src/thenable-status.ts +0 -11
  255. package/src/types/handler-context.ts +12 -1
  256. package/src/types/loader-types.ts +4 -3
  257. package/src/types/route-entry.ts +2 -1
  258. package/src/urls/include-helper.ts +38 -11
  259. package/src/urls/path-helper-types.ts +4 -2
  260. package/src/urls/path-helper.ts +16 -3
  261. package/src/vite/discovery/client-urls-projection.ts +205 -16
  262. package/src/vite/discovery/discover-routers.ts +2 -2
  263. package/src/vite/discovery/prerender-collection.ts +2 -6
  264. package/src/vite/discovery/state.ts +26 -0
  265. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  266. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  267. package/src/vite/plugins/hoist/source.ts +8 -7
  268. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  269. package/src/vite/plugins/version-plugin.ts +7 -0
  270. package/src/vite/plugins/version.d.ts +7 -0
  271. package/src/vite/plugins/virtual-entries.ts +20 -0
  272. package/src/vite/rango.ts +4 -1
  273. package/src/vite/router-discovery.ts +39 -7
  274. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -6,7 +6,7 @@
6
6
  * reference. We detect these and return HTML instead of RSC stream.
7
7
  */
8
8
 
9
- import { requestHeaders } from "../server/request-headers.js";
9
+ import { bodylessHeaders, requestHeaders } from "../server/request-headers.js";
10
10
  import {
11
11
  getRequestContext,
12
12
  setRequestContextParams,
@@ -16,10 +16,12 @@ import type { MiddlewareFn } from "../router/middleware.js";
16
16
  import { executeMiddleware } from "../router/middleware.js";
17
17
  import { observePhase, PHASES } from "../router/instrument.js";
18
18
  import { attachTransitionWhen } from "./attach-transition-when.js";
19
- import { payloadInitialTheme } from "./full-payload.js";
19
+ import { payloadInitialTheme, payloadLocation } from "./full-payload.js";
20
20
  import { resolvedHandleStream } from "../handles/deferred-resolution.js";
21
21
  import type { RscPayload, ReactFormState } from "./types.js";
22
+ import type { MatchResult } from "../types.js";
22
23
  import {
24
+ actionRenderRequest,
23
25
  createResponseWithMergedHeaders,
24
26
  finalizeResponse,
25
27
  buildRouteMiddlewareEntries,
@@ -78,17 +80,30 @@ export async function handleProgressiveEnhancement<TEnv>(
78
80
  // renders and the re-render), so a PE request prints ONE tree. A non-PE form
79
81
  // POST (no $ACTION fields) drives no plan and stays silent.
80
82
  const trace = INTERNAL_RANGO_DEBUG ? createRoutineTrace("pe") : undefined;
83
+ const input: PeRequestInput<TEnv> = {
84
+ ctx,
85
+ request,
86
+ env,
87
+ url,
88
+ handleStore,
89
+ nonce,
90
+ routeMwInfo,
91
+ trace,
92
+ };
81
93
  try {
82
- return await handleProgressiveEnhancementInner(
83
- ctx,
84
- request,
85
- env,
86
- url,
87
- handleStore,
88
- nonce,
89
- routeMwInfo,
90
- trace,
91
- );
94
+ const reqCtx = getRequestContext();
95
+ // A rewrite pass after the form's action ran: re-render, never run it again.
96
+ const ran = reqCtx._actionRan;
97
+ if (ran?.kind === "form") {
98
+ return await rerenderAfterFormAction(
99
+ input,
100
+ await outcomeForRewritePass(input, ran.outcome),
101
+ );
102
+ }
103
+ const outcome = await runFormAction(input);
104
+ if (!outcome || outcome instanceof Response) return outcome;
105
+ reqCtx._actionRan = { kind: "form", outcome };
106
+ return await rerenderAfterFormAction(input, outcome);
92
107
  } finally {
93
108
  if (trace && trace.entries.length > 0) {
94
109
  console.log(
@@ -98,16 +113,27 @@ export async function handleProgressiveEnhancement<TEnv>(
98
113
  }
99
114
  }
100
115
 
101
- async function handleProgressiveEnhancementInner<TEnv>(
102
- ctx: HandlerContext<TEnv>,
103
- request: Request,
104
- env: TEnv,
105
- url: URL,
106
- handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
107
- nonce: string | undefined,
108
- routeMwInfo: PeRouteMiddlewareInfo | undefined,
109
- trace: RoutineTrace | undefined,
110
- ): Promise<Response | null> {
116
+ /** One PE request's inputs, shared by the action run and the re-render. */
117
+ interface PeRequestInput<TEnv> {
118
+ ctx: HandlerContext<TEnv>;
119
+ request: Request;
120
+ env: TEnv;
121
+ url: URL;
122
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"];
123
+ nonce: string | undefined;
124
+ routeMwInfo: PeRouteMiddlewareInfo | undefined;
125
+ trace: RoutineTrace | undefined;
126
+ }
127
+
128
+ /**
129
+ * Run the no-JS form's action, outside route middleware. Returns the outcome
130
+ * the re-render renders from, a Response that ends the request (a redirect, a
131
+ * 400 for an unparseable body), or null for a POST that is not a form action.
132
+ */
133
+ async function runFormAction<TEnv>(
134
+ input: PeRequestInput<TEnv>,
135
+ ): Promise<FormActionOutcome | Response | null> {
136
+ const { ctx, request, env, url } = input;
111
137
  // Clone the request to read FormData without consuming it.
112
138
  // Wrap in try-catch so malformed POST bodies are reported as action
113
139
  // errors, not routing errors from the outer catch in handler.ts.
@@ -116,32 +142,10 @@ async function handleProgressiveEnhancementInner<TEnv>(
116
142
  formData = await request.clone().formData();
117
143
  } catch (error) {
118
144
  // Attempt error boundary rendering so the user sees a meaningful page.
119
- const errorHtml = await renderPeErrorBoundary(
120
- ctx,
121
- request,
122
- env,
123
- url,
124
- error,
125
- handleStore,
126
- nonce,
127
- trace,
128
- );
129
- if (errorHtml) {
130
- ctx.callOnError(error, "action", {
131
- request,
132
- url,
133
- env,
134
- handledByBoundary: true,
135
- });
136
- return errorHtml;
145
+ const failure = await matchPeFailure(input, error, undefined, false);
146
+ if (failure.boundary) {
147
+ return { reactFormState: null, directActionId: null, failure };
137
148
  }
138
-
139
- ctx.callOnError(error, "action", {
140
- request,
141
- url,
142
- env,
143
- handledByBoundary: false,
144
- });
145
149
  console.error("[RSC] Progressive enhancement form parse error:", error);
146
150
  return createResponseWithMergedHeaders(null, { status: 400 });
147
151
  }
@@ -167,13 +171,7 @@ async function handleProgressiveEnhancementInner<TEnv>(
167
171
  // Execute action and return HTML
168
172
  let actionResult: unknown = undefined;
169
173
  let reactFormState: ReactFormState | null = null;
170
- // Status for the fall-through re-render after a boundaryless action error.
171
- // When an action throws and NO error boundary matches, the PE path re-renders
172
- // the page (below) the same way it would after a successful action. Without
173
- // this the re-render serves HTTP 200, diverging from the JS path which serves
174
- // 500 (server-action.ts sets actionStatus=500 for the same boundaryless error).
175
- // 500 is carried only into the final HTML response, never the redirect branch.
176
- let boundarylessErrorStatus: number | undefined;
174
+ let failure: PeFailure | undefined;
177
175
 
178
176
  if (isUseActionState) {
179
177
  // Decode and extract action identity before execution so error
@@ -192,33 +190,7 @@ async function handleProgressiveEnhancementInner<TEnv>(
192
190
  // Handle thrown redirect (e.g., throw redirect('/path'))
193
191
  const redirectResponse = extractRedirectResponse(error);
194
192
  if (redirectResponse) return redirectResponse;
195
-
196
- // Attempt error boundary rendering for the PE path
197
- const errorHtml = await renderPeErrorBoundary(
198
- ctx,
199
- request,
200
- env,
201
- url,
202
- error,
203
- handleStore,
204
- nonce,
205
- trace,
206
- useActionStateId,
207
- true, // an action ran and threw
208
- );
209
- if (errorHtml) return errorHtml;
210
-
211
- ctx.callOnError(error, "action", {
212
- request,
213
- url,
214
- env,
215
- actionId: useActionStateId,
216
- handledByBoundary: false,
217
- });
218
- console.error("[RSC] Progressive enhancement action error:", error);
219
- // No boundary matched — the fall-through re-render must carry 500 to match
220
- // the JS path's boundaryless-error status (server-action.ts).
221
- boundarylessErrorStatus = 500;
193
+ failure = await matchPeFailure(input, error, useActionStateId, true);
222
194
  }
223
195
  } else if (isDirectAction && directActionId) {
224
196
  const temporaryReferences = ctx.createTemporaryReferenceSet();
@@ -247,36 +219,17 @@ async function handleProgressiveEnhancementInner<TEnv>(
247
219
  // Handle thrown redirect (e.g., throw redirect('/path'))
248
220
  const redirectResponse = extractRedirectResponse(error);
249
221
  if (redirectResponse) return redirectResponse;
250
-
251
- // Attempt error boundary rendering for the PE path
252
- const errorHtml = await renderPeErrorBoundary(
253
- ctx,
254
- request,
255
- env,
256
- url,
257
- error,
258
- handleStore,
259
- nonce,
260
- trace,
261
- directActionId,
262
- true, // an action ran and threw
263
- );
264
- if (errorHtml) return errorHtml;
265
-
266
- ctx.callOnError(error, "action", {
267
- request,
268
- url,
269
- env,
270
- actionId: directActionId,
271
- handledByBoundary: false,
272
- });
273
- console.error("[RSC] Progressive enhancement action error:", error);
274
- // No boundary matched — the fall-through re-render must carry 500 to match
275
- // the JS path's boundaryless-error status (server-action.ts).
276
- boundarylessErrorStatus = 500;
222
+ failure = await matchPeFailure(input, error, directActionId, true);
277
223
  }
278
224
  }
279
225
 
226
+ if (failure?.boundary) {
227
+ return { reactFormState: null, directActionId, failure };
228
+ }
229
+ if (failure) {
230
+ console.error("[RSC] Progressive enhancement action error:", failure.error);
231
+ }
232
+
280
233
  // Handle Response returned from action during PE.
281
234
  // In the JS path, executeServerAction intercepts redirect Responses and
282
235
  // short-circuits. The PE path must handle them too.
@@ -304,6 +257,58 @@ async function handleProgressiveEnhancementInner<TEnv>(
304
257
  console.error("[RSC] Failed to decode form state:", error);
305
258
  }
306
259
 
260
+ return { reactFormState, directActionId, failure };
261
+ }
262
+
263
+ /** What a no-JS form post's action left for the re-render (`_actionRan`). */
264
+ export interface FormActionOutcome {
265
+ reactFormState: ReactFormState | null;
266
+ directActionId: string | null;
267
+ /** The action threw, or the form body did not parse. */
268
+ failure?: PeFailure;
269
+ }
270
+
271
+ /**
272
+ * A failed no-JS form post and the error boundary matched for it. The boundary
273
+ * renders in place of the page, inside route middleware as the JS path renders
274
+ * its own (server-action.ts `errorBoundary`). With none, the page re-renders
275
+ * with 500, the JS path's status for the same boundaryless error.
276
+ */
277
+ export interface PeFailure {
278
+ error: unknown;
279
+ actionId: string | null | undefined;
280
+ boundary: MatchResult | null;
281
+ }
282
+
283
+ /**
284
+ * The outcome for a rewrite pass: a failure's boundary is re-matched on the
285
+ * target, since pass 1 matched (or missed) the rewriting route's
286
+ * (`continuationForRewritePass` in server-action.ts).
287
+ */
288
+ async function outcomeForRewritePass<TEnv>(
289
+ input: PeRequestInput<TEnv>,
290
+ outcome: FormActionOutcome,
291
+ ): Promise<FormActionOutcome> {
292
+ const { failure } = outcome;
293
+ if (!failure) return outcome;
294
+ const boundary = await input.ctx.router.matchError(
295
+ input.request,
296
+ { env: input.env },
297
+ failure.error,
298
+ "route",
299
+ );
300
+ return { ...outcome, failure: { ...failure, boundary } };
301
+ }
302
+
303
+ /** The re-render after a no-JS form post's action, wrapped in route middleware. */
304
+ async function rerenderAfterFormAction<TEnv>(
305
+ input: PeRequestInput<TEnv>,
306
+ outcome: FormActionOutcome,
307
+ ): Promise<Response> {
308
+ const { ctx, request, env, url, handleStore, nonce, routeMwInfo, trace } =
309
+ input;
310
+ const { failure } = outcome;
311
+
307
312
  // Re-render the page and return HTML.
308
313
  // Route middleware wraps the render so context variables, headers, and
309
314
  // cookies set by route middleware are available during re-render — matching
@@ -313,11 +318,7 @@ async function handleProgressiveEnhancementInner<TEnv>(
313
318
  // custom headers) so loaders that read request headers/cookies behave
314
319
  // identically under PE and the JS action path. Drop body-framing headers
315
320
  // from the bodyless GET and force the HTML accept.
316
- const headers = new Headers(requestHeaders(request));
317
- headers.delete("content-type");
318
- headers.delete("content-length");
319
- headers.delete("content-encoding");
320
- headers.delete("transfer-encoding");
321
+ const headers = bodylessHeaders(request);
321
322
  headers.set("accept", "text/html");
322
323
  const renderRequest = new Request(url.toString(), {
323
324
  method: "GET",
@@ -340,14 +341,17 @@ async function handleProgressiveEnhancementInner<TEnv>(
340
341
  renderRequest,
341
342
  handleStore,
342
343
  nonce,
343
- reactFormState,
344
- boundarylessErrorStatus,
345
- directActionId,
344
+ ...outcome,
346
345
  }),
347
346
  { trace, owner: getRequestContext() },
348
347
  );
349
348
  };
350
349
 
350
+ const boundary = failure?.boundary;
351
+ const render = boundary
352
+ ? () => renderPeErrorBoundary(input, failure?.actionId, boundary)
353
+ : renderPage;
354
+
351
355
  // Execute route middleware wrapping the render, if any.
352
356
  // finalizeResponse drains onResponse callbacks that middleware short-circuits
353
357
  // may leave behind (executeMiddleware does not finalize them itself).
@@ -355,19 +359,19 @@ async function handleProgressiveEnhancementInner<TEnv>(
355
359
  return finalizeResponse(
356
360
  await executeMiddleware(
357
361
  buildRouteMiddlewareEntries(routeMwInfo.routeMiddleware),
358
- request,
362
+ actionRenderRequest(request),
359
363
  env,
360
364
  routeMwInfo.variables,
361
- renderPage,
365
+ render,
362
366
  routeMwInfo.routeReverse,
363
367
  ),
364
368
  );
365
369
  }
366
370
 
367
- return renderPage();
371
+ return render();
368
372
  }
369
373
 
370
- interface PeRenderInput<TEnv> {
374
+ interface PeRenderInput<TEnv> extends FormActionOutcome {
371
375
  ctx: HandlerContext<TEnv>;
372
376
  request: Request;
373
377
  env: TEnv;
@@ -375,9 +379,6 @@ interface PeRenderInput<TEnv> {
375
379
  renderRequest: Request;
376
380
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"];
377
381
  nonce: string | undefined;
378
- reactFormState: ReactFormState | null;
379
- boundarylessErrorStatus: number | undefined;
380
- directActionId: string | null;
381
382
  }
382
383
 
383
384
  /** PE re-render: match the bodyless GET mirror, then render full HTML. */
@@ -413,13 +414,13 @@ function renderPeResponse<TEnv>(
413
414
  handleStore,
414
415
  nonce,
415
416
  reactFormState,
416
- boundarylessErrorStatus,
417
+ failure,
417
418
  directActionId,
418
419
  } = input;
419
420
 
420
421
  const payload: RscPayload = {
421
422
  metadata: {
422
- pathname: url.pathname,
423
+ ...payloadLocation(getRequestContext(), url),
423
424
  routerId: ctx.router.id,
424
425
  basename: ctx.router.basename,
425
426
  segments: attachTransitionWhen(match.segments, getRequestContext()),
@@ -461,13 +462,10 @@ function renderPeResponse<TEnv>(
461
462
  url,
462
463
  payload,
463
464
  init: {
464
- // boundarylessErrorStatus is set only when the action threw and no error
465
- // boundary matched; it makes the re-render carry 500 like the JS path.
466
- // The redirect branch in peRenderPlan returns before this, so a redirect
467
- // re-render keeps its 308 and is never overridden.
468
- ...(boundarylessErrorStatus !== undefined
469
- ? { status: boundarylessErrorStatus }
470
- : {}),
465
+ // A failure reaching the page render matched no boundary: 500, like
466
+ // the JS path. The redirect branch in peRenderPlan returns before
467
+ // this, so a redirect re-render keeps its 308.
468
+ ...(failure ? { status: 500 } : {}),
471
469
  headers: { "content-type": "text/html;charset=utf-8" },
472
470
  },
473
471
  tracking: stageTracking,
@@ -487,80 +485,22 @@ function renderPeResponse<TEnv>(
487
485
  );
488
486
  }
489
487
 
490
- interface PeErrorBoundaryInput<TEnv> {
491
- ctx: HandlerContext<TEnv>;
492
- request: Request;
493
- env: TEnv;
494
- url: URL;
495
- error: unknown;
496
- handleStore: ReturnType<typeof getRequestContext>["_handleStore"];
497
- nonce: string | undefined;
498
- actionId: string | null | undefined;
499
- actionRan: boolean;
500
- }
501
-
502
- type PeErrorMatch<TEnv> = NonNullable<
503
- Awaited<ReturnType<HandlerContext<TEnv>["router"]["matchError"]>>
504
- >;
505
-
506
488
  /**
507
- * Attempt to render an error boundary as full HTML for the PE path.
508
- * Returns null if no error boundary is found (caller falls through to
509
- * normal page re-render).
489
+ * Match an error boundary for a failed PE post and report the error, handled
490
+ * or not. A matchError failure reports the ORIGINAL error as unhandled and
491
+ * rethrows the match failure.
510
492
  */
511
- async function renderPeErrorBoundary<TEnv>(
512
- ctx: HandlerContext<TEnv>,
513
- request: Request,
514
- env: TEnv,
515
- url: URL,
493
+ async function matchPeFailure<TEnv>(
494
+ input: PeRequestInput<TEnv>,
516
495
  error: unknown,
517
- handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
518
- nonce: string | undefined,
519
- trace: RoutineTrace | undefined,
520
- actionId?: string | null,
496
+ actionId: string | null | undefined,
521
497
  // True when an action actually ran and threw (vs a malformed form body, where
522
498
  // no action executed). Drives _inActionRevalidation for JS/PE parity — it must
523
499
  // NOT be inferred from actionId, since a useActionState bound action can run
524
500
  // and throw with no $$id (actionId === undefined) yet still be an action error.
525
- actionRan = false,
526
- ): Promise<Response | null> {
527
- return runRoutine(
528
- peErrorBoundaryPlan({
529
- ctx,
530
- request,
531
- env,
532
- url,
533
- error,
534
- handleStore,
535
- nonce,
536
- actionId,
537
- actionRan,
538
- }),
539
- { trace, owner: getRequestContext() },
540
- );
541
- }
542
-
543
- /** PE error boundary: match a boundary for the thrown error, then render it. */
544
- function* peErrorBoundaryPlan<TEnv>(
545
- input: PeErrorBoundaryInput<TEnv>,
546
- ): RoutinePlan<Response | null> {
547
- const boundary = yield* step("match:error", () =>
548
- matchPeErrorBoundary(input),
549
- );
550
- if (!boundary) return null;
551
- return yield* step("render", () => renderPeErrorResponse(input, boundary));
552
- }
553
-
554
- /**
555
- * Match an error boundary for the PE path, owning the reporting protocol:
556
- * a matchError failure reports the ORIGINAL error as unhandled and rethrows
557
- * the match failure; a miss returns null; a hit reports handled and stamps
558
- * params + action gate context before the render.
559
- */
560
- async function matchPeErrorBoundary<TEnv>(
561
- input: PeErrorBoundaryInput<TEnv>,
562
- ): Promise<PeErrorMatch<TEnv> | null> {
563
- const { ctx, request, env, url, error, actionId, actionRan } = input;
501
+ actionRan: boolean,
502
+ ): Promise<PeFailure> {
503
+ const { ctx, request, env, url } = input;
564
504
 
565
505
  // JS/PE parity for an action-triggered error re-render: a stale
566
506
  // `foregroundOnAction` cache entry inside the error boundary must foreground
@@ -572,9 +512,9 @@ async function matchPeErrorBoundary<TEnv>(
572
512
  getRequestContext()._inActionRevalidation = true;
573
513
  }
574
514
 
575
- let errorResult;
515
+ let boundary: MatchResult | null;
576
516
  try {
577
- errorResult = await ctx.router.matchError(request, { env }, error, "route");
517
+ boundary = await ctx.router.matchError(request, { env }, error, "route");
578
518
  } catch (matchErr) {
579
519
  ctx.callOnError(error, "action", {
580
520
  request,
@@ -586,31 +526,41 @@ async function matchPeErrorBoundary<TEnv>(
586
526
  throw matchErr;
587
527
  }
588
528
 
589
- if (!errorResult) return null;
590
-
591
529
  ctx.callOnError(error, "action", {
592
530
  request,
593
531
  url,
594
532
  env,
595
533
  actionId: actionId ?? undefined,
596
- handledByBoundary: true,
534
+ handledByBoundary: !!boundary,
597
535
  });
536
+ return { error, actionId, boundary };
537
+ }
598
538
 
599
- setRequestContextParams(errorResult.params, errorResult.routeName);
600
-
601
- return errorResult;
539
+ /** Render a failed PE post's boundary, inside route middleware. */
540
+ function renderPeErrorBoundary<TEnv>(
541
+ input: PeRequestInput<TEnv>,
542
+ actionId: string | null | undefined,
543
+ boundary: MatchResult,
544
+ ): Promise<Response> {
545
+ return runRoutine(
546
+ step("render", () => renderPeErrorResponse(input, actionId, boundary)),
547
+ { trace: input.trace, owner: getRequestContext() },
548
+ );
602
549
  }
603
550
 
604
551
  /** Render the matched PE error boundary as a full HTML document. */
605
552
  function renderPeErrorResponse<TEnv>(
606
- input: PeErrorBoundaryInput<TEnv>,
607
- errorResult: PeErrorMatch<TEnv>,
553
+ input: PeRequestInput<TEnv>,
554
+ actionId: string | null | undefined,
555
+ errorResult: MatchResult,
608
556
  ): Promise<Response> {
609
- const { ctx, request, env, url, handleStore, nonce, actionId } = input;
557
+ const { ctx, request, env, url, handleStore, nonce } = input;
558
+
559
+ setRequestContextParams(errorResult.params, errorResult.routeName);
610
560
 
611
561
  const payload: RscPayload = {
612
562
  metadata: {
613
- pathname: url.pathname,
563
+ ...payloadLocation(getRequestContext(), url),
614
564
  routerId: ctx.router.id,
615
565
  basename: ctx.router.basename,
616
566
  segments: attachTransitionWhen(errorResult.segments, getRequestContext()),
@@ -21,7 +21,11 @@ import {
21
21
  rebaseRequestContext,
22
22
  type RequestContext,
23
23
  } from "../server/request-context.js";
24
- import { recordRewriteHop } from "../server/rewrite-trail.js";
24
+ import { bodylessRequest } from "../server/request-headers.js";
25
+ import {
26
+ recordRewriteHop,
27
+ recordRewriteShell,
28
+ } from "../server/rewrite-trail.js";
25
29
 
26
30
  /** Response header naming the route path a rewritten request rendered. */
27
31
  export const REWRITE_HEADER = "x-rango-rewrite";
@@ -45,11 +49,9 @@ export function resolveRewriteTarget(
45
49
  const path =
46
50
  "path" in target
47
51
  ? prefixBasename(target.path, basename)
48
- : // `{}`, not undefined: a route with params then throws for the
49
- // missing ones instead of resolving to its raw pattern.
50
- createReverseFunction(routeMap)(
52
+ : createReverseFunction(routeMap)(
51
53
  target.name,
52
- target.params ?? {},
54
+ target.params,
53
55
  target.search,
54
56
  );
55
57
  const resolved = stripInternalParams(new URL(path, base.origin));
@@ -94,11 +96,38 @@ export interface RewritePass {
94
96
  hops: number;
95
97
  }
96
98
 
99
+ let lateLockReported = false;
100
+
101
+ /**
102
+ * `.disallowMutations()` thrown after the action ran cannot refuse it. Dev
103
+ * warns on every throw; production logs one error per isolate, so a lock
104
+ * that never refused anything is not silent there.
105
+ */
106
+ function reportLateLock(rewrite: Rewrite): void {
107
+ const message = `[rango] ${describeRewriteTarget(rewrite.target)}.disallowMutations() came after the server action or form post ran, so it cannot refuse it: route middleware, and global middleware on a later rewritten pass, run only after the action. The render moves to the target. To refuse mutations, throw it from global middleware (router.use()) matched on the URL the form posts to.`;
108
+ if (process.env.NODE_ENV !== "production") {
109
+ console.warn(message);
110
+ } else if (!lateLockReported) {
111
+ lateLockReported = true;
112
+ console.error(message);
113
+ }
114
+ }
115
+
97
116
  /**
98
117
  * Move the request context to `rewrite`'s target for the next pass: check
99
118
  * that this request can be rewritten, resolve the target, count the hop,
100
- * then rebase the context. Throws the site error for a mutation or a loader
101
- * fetch, a resolution error, and RewriteLoopError.
119
+ * then rebase the context. Throws the site error for a loader fetch, a
120
+ * refused mutation or one whose body middleware read before its action ran,
121
+ * a resolution error, and RewriteLoopError.
122
+ *
123
+ * A mutation moves too (#1145). Before its action ran (a rewrite from global
124
+ * middleware) it moves with its body, and the target's pass runs the action
125
+ * after the target's global middleware, as a direct post there would. After
126
+ * it ran (route middleware wraps only the re-render), the target's pass
127
+ * re-renders from `reqCtx._actionRan`. `disallowMutations()` refuses the
128
+ * first, so a lock never runs a mutation; the second is already committed,
129
+ * and refusing it reports an error for a write that happened, so the caller
130
+ * retries it.
102
131
  */
103
132
  export function applyRewrite(
104
133
  rewrite: Rewrite,
@@ -113,12 +142,30 @@ export function applyRewrite(
113
142
  throw rewriteNotSupported(rewrite, "loader-fetch");
114
143
  }
115
144
  if (passRequest.method !== "GET" && passRequest.method !== "HEAD") {
116
- throw rewriteNotSupported(rewrite, "mutation");
145
+ if (!reqCtx._actionRan) {
146
+ if (rewrite.mutationsDisallowed) {
147
+ throw rewriteNotSupported(rewrite, "mutation");
148
+ }
149
+ // The target's pass hands the body to the action: one middleware read
150
+ // would run it with no arguments.
151
+ if (passRequest.bodyUsed) throw rewriteNotSupported(rewrite, "body-read");
152
+ } else if (rewrite.mutationsDisallowed) {
153
+ reportLateLock(rewrite);
154
+ }
117
155
  }
118
156
  const target = resolveRewriteTarget(rewrite, routeMap, reqCtx.url, basename);
119
157
  const hops = recordRewriteHop(reqCtx, reqCtx.url, target);
158
+ recordRewriteShell(reqCtx, rewrite.shellAudience);
120
159
  const url = rewriteTransportUrl(target, reqCtx.originalUrl);
121
- const request = new Request(url, passRequest);
160
+ // A mutation keeps its body: one whose action has not run yet, and a no-JS
161
+ // form post, since the PE handler reads a clone, so a form-field check
162
+ // reads it on every pass. A JS action that ran consumed it
163
+ // (`new Request(url, used)` throws): the same request without it or its
164
+ // content-type, so a body check gated on the content-type passes over this
165
+ // pass, which never runs the action.
166
+ const request = passRequest.bodyUsed
167
+ ? bodylessRequest(url, passRequest, passRequest.method)
168
+ : new Request(url, passRequest);
122
169
  rebaseRequestContext(reqCtx, request, url);
123
170
  return { request, url, target, hops };
124
171
  }