@rangojs/router 0.24.4 → 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 (267) 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-urls.d.ts +6 -2
  23. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  24. package/dist/types/client-urls/server-projection.d.ts +9 -5
  25. package/dist/types/client-urls/types.d.ts +29 -16
  26. package/dist/types/client.d.ts +2 -2
  27. package/dist/types/client.rsc.d.ts +2 -2
  28. package/dist/types/decode-loader-results.d.ts +28 -7
  29. package/dist/types/href-client.d.ts +3 -1
  30. package/dist/types/index.rsc.d.ts +1 -1
  31. package/dist/types/loader-redirect.d.ts +2 -3
  32. package/dist/types/outlet-context.d.ts +7 -6
  33. package/dist/types/prefetch-markers.d.ts +14 -5
  34. package/dist/types/route-content-wrapper.d.ts +17 -14
  35. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  36. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  37. package/dist/types/route-definition/rewrite.d.ts +48 -5
  38. package/dist/types/route-types.d.ts +2 -2
  39. package/dist/types/router/handler-context.d.ts +14 -2
  40. package/dist/types/router/intercept-resolution.d.ts +19 -46
  41. package/dist/types/router/lazy-includes.d.ts +7 -0
  42. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  43. package/dist/types/router/middleware.d.ts +0 -16
  44. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  45. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  46. package/dist/types/router/revalidation.d.ts +8 -1
  47. package/dist/types/router/router-context.d.ts +4 -16
  48. package/dist/types/router/router-interfaces.d.ts +0 -6
  49. package/dist/types/router/router-options.d.ts +4 -7
  50. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  51. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  52. package/dist/types/router/segment-resolution.d.ts +1 -1
  53. package/dist/types/router/segment-wrappers.d.ts +3 -5
  54. package/dist/types/router/substitute-pattern-params.d.ts +3 -2
  55. package/dist/types/router/tracing.d.ts +2 -2
  56. package/dist/types/rsc/full-payload.d.ts +14 -2
  57. package/dist/types/rsc/helpers.d.ts +8 -0
  58. package/dist/types/rsc/manifest-init.d.ts +20 -0
  59. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  60. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  61. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  62. package/dist/types/rsc/server-action.d.ts +6 -0
  63. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  64. package/dist/types/rsc/shell-capture.d.ts +7 -0
  65. package/dist/types/rsc/types.d.ts +5 -0
  66. package/dist/types/segment-system.d.ts +15 -0
  67. package/dist/types/server/context.d.ts +64 -4
  68. package/dist/types/server/request-context.d.ts +41 -4
  69. package/dist/types/server/request-headers.d.ts +12 -0
  70. package/dist/types/server/rewrite-trail.d.ts +8 -1
  71. package/dist/types/testing/e2e/index.d.ts +1 -1
  72. package/dist/types/testing/flight.entry.d.ts +1 -1
  73. package/dist/types/testing/index.d.ts +1 -1
  74. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  75. package/dist/types/testing/render-route.d.ts +21 -11
  76. package/dist/types/testing/run-loader.d.ts +7 -0
  77. package/dist/types/testing/run-middleware.d.ts +7 -0
  78. package/dist/types/testing/run-revalidate.d.ts +5 -4
  79. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  80. package/dist/types/testing/shell-status.d.ts +17 -0
  81. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  82. package/dist/types/testing/vitest.d.ts +10 -1
  83. package/dist/types/thenable-status.d.ts +0 -6
  84. package/dist/types/types/handler-context.d.ts +12 -1
  85. package/dist/types/types/loader-types.d.ts +4 -3
  86. package/dist/types/types/route-entry.d.ts +2 -1
  87. package/dist/types/urls/path-helper-types.d.ts +4 -2
  88. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  89. package/dist/types/vite/discovery/state.d.ts +20 -0
  90. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  91. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  92. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  93. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  94. package/dist/vite/index.js +6816 -6547
  95. package/package.json +1 -1
  96. package/skills/app-structure/SKILL.md +255 -0
  97. package/skills/app-structure/content-site.md +333 -0
  98. package/skills/app-structure/e-commerce.md +435 -0
  99. package/skills/app-structure/large-app.md +885 -0
  100. package/skills/app-structure/multi-app.md +280 -0
  101. package/skills/breadcrumbs/SKILL.md +6 -1
  102. package/skills/caching/SKILL.md +3 -1
  103. package/skills/catalog.json +12 -0
  104. package/skills/client-urls/SKILL.md +185 -32
  105. package/skills/composability/SKILL.md +10 -8
  106. package/skills/handler-use/SKILL.md +48 -9
  107. package/skills/hooks/handle-and-actions.md +104 -0
  108. package/skills/hooks/navigation.md +24 -10
  109. package/skills/hooks/urls.md +1 -1
  110. package/skills/i18n/SKILL.md +2 -1
  111. package/skills/intercept/SKILL.md +60 -29
  112. package/skills/layout/SKILL.md +6 -3
  113. package/skills/links/SKILL.md +151 -17
  114. package/skills/loader/SKILL.md +32 -10
  115. package/skills/middleware/SKILL.md +111 -15
  116. package/skills/observability/SKILL.md +11 -0
  117. package/skills/parallel/SKILL.md +5 -5
  118. package/skills/ppr/SKILL.md +43 -8
  119. package/skills/prerender/SKILL.md +1 -2
  120. package/skills/rango/SKILL.md +3 -0
  121. package/skills/response-routes/SKILL.md +4 -1
  122. package/skills/scripts/SKILL.md +12 -1
  123. package/skills/server-actions/SKILL.md +60 -20
  124. package/skills/testing/SKILL.md +1 -0
  125. package/skills/testing/client-components.md +13 -10
  126. package/skills/testing/loader.md +25 -24
  127. package/skills/testing/middleware.md +11 -11
  128. package/skills/testing/response-routes.md +4 -1
  129. package/skills/testing/serve-shell-request.md +85 -21
  130. package/skills/ux-architecture/SKILL.md +980 -0
  131. package/skills/view-transitions/SKILL.md +43 -10
  132. package/src/__augment-tests__/augmented.check.ts +28 -0
  133. package/src/browser/event-controller.ts +44 -18
  134. package/src/browser/intercept-utils.ts +3 -2
  135. package/src/browser/merge-segment-loaders.ts +0 -85
  136. package/src/browser/navigation-bridge.ts +112 -84
  137. package/src/browser/navigation-client.ts +20 -15
  138. package/src/browser/navigation-store.ts +4 -2
  139. package/src/browser/navigation-transaction.ts +9 -8
  140. package/src/browser/partial-update.ts +90 -32
  141. package/src/browser/prefetch/cache.ts +8 -0
  142. package/src/browser/prefetch/fetch.ts +13 -3
  143. package/src/browser/rango-marker.ts +13 -0
  144. package/src/browser/react/NavigationProvider.tsx +25 -7
  145. package/src/browser/react/filter-segment-order.ts +13 -0
  146. package/src/browser/react/use-action.ts +9 -15
  147. package/src/browser/react/use-handle.ts +63 -71
  148. package/src/browser/react/use-href.tsx +24 -5
  149. package/src/browser/react/use-link-status.ts +1 -1
  150. package/src/browser/react/use-navigation.ts +30 -40
  151. package/src/browser/react/use-router.ts +37 -6
  152. package/src/browser/react/use-segments.ts +8 -12
  153. package/src/browser/response-adapter.ts +11 -5
  154. package/src/browser/rsc-router.tsx +3 -1
  155. package/src/browser/scroll-restoration.ts +34 -20
  156. package/src/browser/segment-reconciler.ts +11 -29
  157. package/src/browser/server-action-bridge.ts +52 -16
  158. package/src/browser/types.ts +40 -8
  159. package/src/build/generate-manifest.ts +12 -1
  160. package/src/build/route-types/codegen.ts +6 -2
  161. package/src/build/route-types/per-module-writer.ts +94 -61
  162. package/src/cache/cache-runtime.ts +9 -0
  163. package/src/cache/cache-scope.ts +2 -1
  164. package/src/cache/cf/cf-cache-store.ts +2 -0
  165. package/src/cache/cf/cf-shell-frame.ts +3 -0
  166. package/src/cache/types.ts +8 -0
  167. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  168. package/src/client-urls/client-urls.ts +202 -153
  169. package/src/client-urls/revalidate-registry.ts +12 -3
  170. package/src/client-urls/server-projection.ts +137 -66
  171. package/src/client-urls/types.ts +30 -15
  172. package/src/client.rsc.tsx +11 -1
  173. package/src/client.tsx +9 -1
  174. package/src/decode-loader-results.ts +64 -31
  175. package/src/href-client.ts +3 -1
  176. package/src/index.rsc.ts +1 -0
  177. package/src/index.ts +12 -4
  178. package/src/loader-redirect.tsx +2 -3
  179. package/src/outlet-context.ts +7 -6
  180. package/src/prefetch-markers.ts +15 -5
  181. package/src/reverse.ts +3 -3
  182. package/src/route-content-wrapper.tsx +19 -17
  183. package/src/route-definition/dsl-helpers.ts +27 -7
  184. package/src/route-definition/helpers-types.ts +7 -2
  185. package/src/route-definition/resolve-handler-use.ts +23 -10
  186. package/src/route-definition/rewrite.ts +86 -19
  187. package/src/route-types.ts +1 -2
  188. package/src/router/find-match.ts +37 -1
  189. package/src/router/handler-context.ts +85 -40
  190. package/src/router/intercept-resolution.ts +161 -307
  191. package/src/router/lazy-includes.ts +43 -27
  192. package/src/router/loader-resolution.ts +21 -1
  193. package/src/router/manifest.ts +6 -8
  194. package/src/router/match-api.ts +11 -1
  195. package/src/router/match-middleware/background-revalidation.ts +2 -4
  196. package/src/router/match-middleware/cache-lookup.ts +16 -20
  197. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  198. package/src/router/match-result.ts +31 -2
  199. package/src/router/middleware.ts +6 -147
  200. package/src/router/navigation-snapshot.ts +38 -2
  201. package/src/router/prefetch-cache-ttl.ts +10 -18
  202. package/src/router/prerender-match.ts +2 -1
  203. package/src/router/revalidation.ts +24 -3
  204. package/src/router/router-context.ts +5 -18
  205. package/src/router/router-interfaces.ts +0 -7
  206. package/src/router/router-options.ts +4 -7
  207. package/src/router/segment-resolution/fresh.ts +6 -2
  208. package/src/router/segment-resolution/helpers.ts +7 -1
  209. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  210. package/src/router/segment-resolution/revalidation.ts +75 -35
  211. package/src/router/segment-resolution.ts +0 -1
  212. package/src/router/segment-wrappers.ts +8 -13
  213. package/src/router/substitute-pattern-params.ts +3 -2
  214. package/src/router/tracing.ts +2 -2
  215. package/src/router.ts +2 -9
  216. package/src/rsc/full-payload.ts +26 -4
  217. package/src/rsc/handler.ts +83 -41
  218. package/src/rsc/helpers.ts +13 -0
  219. package/src/rsc/manifest-init.ts +56 -3
  220. package/src/rsc/prefetch-render-error.ts +38 -0
  221. package/src/rsc/progressive-enhancement.ts +161 -211
  222. package/src/rsc/rewrite-passes.ts +56 -9
  223. package/src/rsc/rsc-rendering.ts +79 -23
  224. package/src/rsc/server-action.ts +25 -2
  225. package/src/rsc/shell-capture-constants.ts +22 -0
  226. package/src/rsc/shell-capture.ts +11 -0
  227. package/src/rsc/types.ts +5 -0
  228. package/src/segment-system.tsx +125 -119
  229. package/src/server/context.ts +96 -5
  230. package/src/server/handle-store.ts +3 -2
  231. package/src/server/request-context.ts +42 -3
  232. package/src/server/request-headers.ts +32 -0
  233. package/src/server/rewrite-trail.ts +55 -9
  234. package/src/ssr/ssr-root.tsx +6 -10
  235. package/src/testing/dispatch.ts +4 -5
  236. package/src/testing/e2e/index.ts +1 -0
  237. package/src/testing/flight.entry.ts +1 -0
  238. package/src/testing/index.ts +1 -0
  239. package/src/testing/internal/client-urls-projection.ts +31 -11
  240. package/src/testing/render-route.tsx +83 -40
  241. package/src/testing/run-loader.ts +26 -2
  242. package/src/testing/run-middleware.ts +10 -0
  243. package/src/testing/run-revalidate.ts +13 -6
  244. package/src/testing/serve-shell-request.ts +77 -4
  245. package/src/testing/shell-status.ts +40 -0
  246. package/src/testing/vitest-stubs/version.ts +1 -0
  247. package/src/testing/vitest.ts +74 -7
  248. package/src/thenable-status.ts +0 -11
  249. package/src/types/handler-context.ts +12 -1
  250. package/src/types/loader-types.ts +4 -3
  251. package/src/types/route-entry.ts +2 -1
  252. package/src/urls/include-helper.ts +38 -11
  253. package/src/urls/path-helper-types.ts +4 -2
  254. package/src/vite/discovery/client-urls-projection.ts +205 -16
  255. package/src/vite/discovery/discover-routers.ts +2 -2
  256. package/src/vite/discovery/prerender-collection.ts +2 -6
  257. package/src/vite/discovery/state.ts +26 -0
  258. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  259. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  260. package/src/vite/plugins/hoist/source.ts +8 -7
  261. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  262. package/src/vite/plugins/version-plugin.ts +7 -0
  263. package/src/vite/plugins/version.d.ts +7 -0
  264. package/src/vite/plugins/virtual-entries.ts +20 -0
  265. package/src/vite/rango.ts +4 -1
  266. package/src/vite/router-discovery.ts +39 -7
  267. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -25,6 +25,8 @@ import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
25
25
  import { formatCacheSignalHeader } from "../router/telemetry.js";
26
26
  import type { RscPayload } from "./types.js";
27
27
  import { rewriteTrail, visitorUrl } from "../server/rewrite-trail.js";
28
+ import { guardRequestHeaders } from "../server/cookie-store.js";
29
+ import { bodylessRequest } from "../server/request-headers.js";
28
30
  import type { HandlerContext } from "./handler-context.js";
29
31
 
30
32
  /**
@@ -380,6 +382,17 @@ export function buildRouteMiddlewareEntries<TEnv>(
380
382
  }));
381
383
  }
382
384
 
385
+ /**
386
+ * The request route middleware sees around the render after a server action
387
+ * or a no-JS form post: the page's GET (URL, headers without body framing or
388
+ * content-type, signal, workerd `cf`; no body). Route middleware never wraps the action,
389
+ * only that render, so it decides as for a GET of the page: a rewrite it
390
+ * skips for mutations still moves the render to its target (#1145).
391
+ */
392
+ export function actionRenderRequest(request: Request): Request {
393
+ return guardRequestHeaders(bodylessRequest(request.url, request, "GET"));
394
+ }
395
+
383
396
  /**
384
397
  * Merge stub headers from the request context onto an existing Response in
385
398
  * place, then drain onResponse callbacks. Used when a Response cannot flow
@@ -6,8 +6,12 @@
6
6
  */
7
7
 
8
8
  import {
9
+ ensureRouterManifest,
9
10
  getGlobalRouteMap,
11
+ getRouterManifest,
12
+ getRouterTrie,
10
13
  hasCachedManifest,
14
+ isRouterTrieAuthoritative,
11
15
  setCachedManifest,
12
16
  setRouterManifest,
13
17
  setRouterTrie,
@@ -15,6 +19,55 @@ import {
15
19
  } from "../route-map-builder.js";
16
20
  import { mergeFullManifests } from "../build/merge-full-manifests.js";
17
21
  import { isTransitionWhenValidationEnabled } from "../transition-when-ref.js";
22
+ import type { TrieNode } from "../router/route-trie-builder.js";
23
+
24
+ // A zero-route router builds no trie; recording that keeps it from rebuilding.
25
+ const NO_TRIE: unique symbol = Symbol("rango.noTrie");
26
+
27
+ // The trie each router object last installed. Router ids need not be unique
28
+ // across instances (a test helper calling createRouter({ id }) per test), and
29
+ // the per-id trie map cannot tell which instance a trie came from.
30
+ const installedTries = new WeakMap<object, TrieNode | typeof NO_TRIE>();
31
+
32
+ /** True when the trie installed for `router.id` was built from this router. */
33
+ export function isRouterTrieBuiltFrom(router: { id?: string }): boolean {
34
+ if (router.id === undefined) return false;
35
+ const installed = installedTries.get(router);
36
+ return (
37
+ installed !== undefined &&
38
+ installed === (getRouterTrie(router.id) ?? NO_TRIE)
39
+ );
40
+ }
41
+
42
+ /** The router fields ensureRouterTrie reads. */
43
+ export interface TrieSource {
44
+ id?: string;
45
+ urlpatterns?: unknown;
46
+ routeMap: Record<string, unknown>;
47
+ }
48
+
49
+ /**
50
+ * Install the trie `router` matches through, as a request finds it: the build
51
+ * manifest's trie when one is registered for its id, otherwise one built from
52
+ * its routes the way the dev handler builds it (rsc/handler.ts). Ids can repeat
53
+ * across router objects (tests), and another object's trie names other routes,
54
+ * so a trie this object did not build is rebuilt. Seeds the per-router manifest
55
+ * from `router.routeMap` when neither source set one, for reverse(). testing's
56
+ * dispatch matches through it, never the regex fallback alone (#1104, #1107).
57
+ */
58
+ export async function ensureRouterTrie(router: TrieSource): Promise<void> {
59
+ const id = router.id;
60
+ if (!id) return;
61
+ if (!isRouterTrieAuthoritative(id) && !isRouterTrieBuiltFrom(router)) {
62
+ await ensureRouterManifest(id);
63
+ if (router.urlpatterns && !isRouterTrieAuthoritative(id)) {
64
+ await buildRouterTrieFromUrlpatterns(router);
65
+ }
66
+ }
67
+ if (!getRouterManifest(id)) {
68
+ setRouterManifest(id, router.routeMap as Record<string, string>);
69
+ }
70
+ }
18
71
 
19
72
  /**
20
73
  * Build a fresh route trie from the router's URL-pattern mounts and store it in
@@ -63,9 +116,9 @@ export async function buildRouterTrieFromUrlpatterns(
63
116
  // are no routes.
64
117
  const { buildPerRouterTrie } = await import("../build/route-trie.js");
65
118
  const trie = buildPerRouterTrie(generated);
66
- if (trie) {
67
- setRouterTrie(router.id, trie);
68
- }
119
+ if (trie) setRouterTrie(router.id, trie);
120
+ // Re-read: setRouterTrie stores a copy (nullPrototypeStaticChildren).
121
+ installedTries.set(router, trie ? getRouterTrie(router.id)! : NO_TRIE);
69
122
  setRouterManifest(router.id, generated.routeManifest);
70
123
 
71
124
  // Match the production discovery path: precompute leaf-include entries so the
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A prefetch whose render reported an error is not kept (#1170).
3
+ *
4
+ * WHY: its 200 left with the headers before a streamed part failed, so the
5
+ * client stored the error boundary as a healthy prefetch, served it to a
6
+ * click and respawned it. The status cannot change any more, and the body
7
+ * cannot end with an error: workerd delivers an errored body as a clean end,
8
+ * and in Chromium the error drops the rows a click that adopted the prefetch
9
+ * has not decoded yet (a Flight "network error" in place of the page).
10
+ *
11
+ * So the body ends cleanly with one more row, PREFETCH_FAILED_ROW, which the
12
+ * Flight decoder skips and teeWithCompletion (browser/response-adapter.ts)
13
+ * reads as a stream that did not end cleanly: the client evicts the prefetch
14
+ * and never respawns it. Prefetch responses carry no Cache-Control, so the
15
+ * browser HTTP cache keeps none of it either.
16
+ *
17
+ * The errors are the render's `_renderErrors`: a Flight error row, a streamed
18
+ * handler recovered into its boundary, a loader that threw (redirect() and
19
+ * notFound() are not errors). Each is recorded before Flight writes the row
20
+ * that carries it, so the list is complete once Flight closes the stream.
21
+ */
22
+
23
+ import { PREFETCH_FAILED_ROW } from "../prefetch-markers.js";
24
+
25
+ export function markPrefetchOnRenderError(
26
+ response: Response,
27
+ renderErrors: readonly unknown[],
28
+ ): Response {
29
+ if (!response.body) return response;
30
+ const marked = new TransformStream<Uint8Array, Uint8Array>({
31
+ flush(controller) {
32
+ if (renderErrors.length > 0) {
33
+ controller.enqueue(new TextEncoder().encode(PREFETCH_FAILED_ROW));
34
+ }
35
+ },
36
+ });
37
+ return new Response(response.body.pipeThrough(marked), response);
38
+ }
@@ -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()),