@rangojs/router 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/dist/bin/rango.js +10 -4
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  7. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  8. package/dist/types/browser/partial-update.d.ts +39 -8
  9. package/dist/types/browser/react/NavigationProvider.d.ts +19 -5
  10. package/dist/types/browser/react/context.d.ts +3 -2
  11. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  12. package/dist/types/browser/response-adapter.d.ts +7 -0
  13. package/dist/types/browser/types.d.ts +24 -10
  14. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  15. package/dist/types/build/generate-route-types.d.ts +1 -1
  16. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  17. package/dist/types/build/route-types/scan-filter.d.ts +22 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  19. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  20. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  21. package/dist/types/cache/document-cache.d.ts +2 -10
  22. package/dist/types/client-urls/client-urls.d.ts +1 -1
  23. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  24. package/dist/types/client-urls/navigation.d.ts +0 -37
  25. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  26. package/dist/types/client-urls/server-projection.d.ts +29 -1
  27. package/dist/types/client-urls/types.d.ts +34 -69
  28. package/dist/types/client.d.ts +0 -1
  29. package/dist/types/context-var.d.ts +12 -0
  30. package/dist/types/errors.d.ts +15 -0
  31. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  32. package/dist/types/handles/pushed-by.d.ts +31 -0
  33. package/dist/types/index.d.ts +4 -0
  34. package/dist/types/index.rsc.d.ts +2 -0
  35. package/dist/types/loader-store.d.ts +8 -0
  36. package/dist/types/prefetch-markers.d.ts +11 -0
  37. package/dist/types/response-utils.d.ts +7 -0
  38. package/dist/types/reverse.d.ts +8 -8
  39. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  40. package/dist/types/route-definition/rewrite.d.ts +56 -0
  41. package/dist/types/route-map-builder.d.ts +7 -2
  42. package/dist/types/router/basename.d.ts +7 -0
  43. package/dist/types/router/handler-context.d.ts +1 -5
  44. package/dist/types/router/internal-params.d.ts +8 -0
  45. package/dist/types/router/is-action.d.ts +2 -1
  46. package/dist/types/router/middleware-types.d.ts +15 -0
  47. package/dist/types/router/revalidation.d.ts +1 -2
  48. package/dist/types/router/route-trie-builder.d.ts +10 -0
  49. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  50. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  51. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +264 -0
  52. package/dist/types/rsc/full-payload.d.ts +11 -0
  53. package/dist/types/rsc/handler.d.ts +33 -0
  54. package/dist/types/rsc/helpers.d.ts +8 -0
  55. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  56. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  57. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  58. package/dist/types/server/context.d.ts +21 -1
  59. package/dist/types/server/handle-store.d.ts +9 -1
  60. package/dist/types/server/request-context.d.ts +52 -2
  61. package/dist/types/server/rewrite-trail.d.ts +49 -0
  62. package/dist/types/server/slot-accessors.d.ts +29 -0
  63. package/dist/types/server.d.ts +1 -0
  64. package/dist/types/testing/flight.entry.d.ts +1 -1
  65. package/dist/types/testing/index.d.ts +4 -4
  66. package/dist/types/testing/internal/action-id.d.ts +10 -1
  67. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  68. package/dist/types/testing/internal/test-action.d.ts +8 -0
  69. package/dist/types/testing/render-route.d.ts +8 -0
  70. package/dist/types/testing/run-loader.d.ts +2 -1
  71. package/dist/types/testing/run-middleware.d.ts +20 -0
  72. package/dist/types/testing/run-revalidate.d.ts +74 -0
  73. package/dist/types/testing/run-transition-when.d.ts +2 -3
  74. package/dist/types/testing/serve-shell-request.d.ts +40 -1
  75. package/dist/types/testing/vitest.d.ts +11 -0
  76. package/dist/types/thenable-status.d.ts +6 -0
  77. package/dist/types/types/boundaries.d.ts +9 -0
  78. package/dist/types/types/handler-context.d.ts +14 -0
  79. package/dist/types/types/loader-types.d.ts +30 -0
  80. package/dist/types/types/request-scope.d.ts +7 -0
  81. package/dist/types/types/segments.d.ts +21 -4
  82. package/dist/types/urls/path-helper-types.d.ts +25 -4
  83. package/dist/types/urls/type-extraction.d.ts +6 -2
  84. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  85. package/dist/types/vite/discovery/state.d.ts +14 -4
  86. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  87. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  88. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  89. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  90. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  91. package/dist/types/vite/router-discovery.d.ts +10 -0
  92. package/dist/vite/index.js +974 -379
  93. package/package.json +1 -1
  94. package/skills/caching/SKILL.md +54 -5
  95. package/skills/catalog.json +3 -3
  96. package/skills/client-urls/SKILL.md +197 -24
  97. package/skills/cloudflare/SKILL.md +14 -9
  98. package/skills/hooks/SKILL.md +16 -3
  99. package/skills/hooks/data.md +3 -1
  100. package/skills/links/SKILL.md +24 -0
  101. package/skills/loader/SKILL.md +199 -2
  102. package/skills/middleware/SKILL.md +183 -2
  103. package/skills/migrate-nextjs/SKILL.md +2 -2
  104. package/skills/migrate-react-router/SKILL.md +10 -7
  105. package/skills/migrate-react-router/data-and-actions.md +1 -1
  106. package/skills/ppr/SKILL.md +22 -0
  107. package/skills/prerender/SKILL.md +22 -8
  108. package/skills/rango/SKILL.md +13 -7
  109. package/skills/route/SKILL.md +139 -0
  110. package/skills/server-actions/SKILL.md +27 -0
  111. package/skills/testing/SKILL.md +3 -2
  112. package/skills/testing/client-components.md +2 -1
  113. package/skills/testing/middleware.md +31 -10
  114. package/skills/testing/predicates.md +67 -32
  115. package/skills/testing/response-routes.md +1 -0
  116. package/skills/testing/serve-shell-request.md +107 -19
  117. package/skills/testing/setup.md +2 -2
  118. package/skills/typesafety/route-types.md +2 -2
  119. package/skills/use-cache/SKILL.md +4 -2
  120. package/skills/view-transitions/SKILL.md +26 -0
  121. package/src/__augment-tests__/augmented.check.ts +20 -0
  122. package/src/browser/defer.ts +82 -0
  123. package/src/browser/document-revealed.ts +52 -0
  124. package/src/browser/event-controller.ts +46 -32
  125. package/src/browser/merge-segment-loaders.ts +31 -18
  126. package/src/browser/navigation-bridge.ts +82 -16
  127. package/src/browser/navigation-client.ts +48 -36
  128. package/src/browser/navigation-store.ts +13 -0
  129. package/src/browser/navigation-transaction.ts +25 -8
  130. package/src/browser/partial-update.ts +554 -146
  131. package/src/browser/prefetch/fetch.ts +2 -1
  132. package/src/browser/react/Link.tsx +7 -9
  133. package/src/browser/react/NavigationProvider.tsx +88 -49
  134. package/src/browser/react/context.ts +3 -2
  135. package/src/browser/react/filter-segment-order.ts +13 -2
  136. package/src/browser/react/use-router.ts +2 -4
  137. package/src/browser/response-adapter.ts +20 -0
  138. package/src/browser/rsc-router.tsx +17 -45
  139. package/src/browser/server-action-bridge.ts +25 -63
  140. package/src/browser/types.ts +24 -10
  141. package/src/browser/validate-redirect-origin.ts +16 -0
  142. package/src/build/generate-route-types.ts +1 -0
  143. package/src/build/route-types/router-processing.ts +3 -6
  144. package/src/build/route-types/scan-filter.ts +45 -11
  145. package/src/cache/cf/cf-cache-store.ts +59 -21
  146. package/src/cache/cf/cf-cache-types.ts +24 -2
  147. package/src/cache/document-cache-policy.ts +131 -0
  148. package/src/cache/document-cache.ts +21 -107
  149. package/src/client-urls/client-urls.ts +22 -6
  150. package/src/client-urls/hoisted-revalidate.ts +25 -0
  151. package/src/client-urls/navigation.ts +0 -96
  152. package/src/client-urls/revalidate-registry.ts +30 -0
  153. package/src/client-urls/server-projection.ts +151 -65
  154. package/src/client-urls/types.ts +39 -71
  155. package/src/client.tsx +9 -5
  156. package/src/context-var.ts +32 -2
  157. package/src/errors.ts +34 -0
  158. package/src/handles/deferred-resolution.ts +28 -5
  159. package/src/handles/pushed-by.ts +148 -0
  160. package/src/index.rsc.ts +8 -0
  161. package/src/index.ts +7 -0
  162. package/src/loader-store.ts +18 -7
  163. package/src/prefetch-markers.ts +13 -0
  164. package/src/response-utils.ts +19 -0
  165. package/src/reverse.ts +17 -14
  166. package/src/route-content-wrapper.tsx +18 -4
  167. package/src/route-definition/dsl-helpers.ts +27 -4
  168. package/src/route-definition/helpers-types.ts +27 -1
  169. package/src/route-definition/redirect.ts +2 -15
  170. package/src/route-definition/rewrite.ts +146 -0
  171. package/src/route-map-builder.ts +14 -11
  172. package/src/router/basename.ts +20 -0
  173. package/src/router/handler-context.ts +3 -13
  174. package/src/router/intercept-resolution.ts +35 -16
  175. package/src/router/internal-params.ts +16 -0
  176. package/src/router/is-action.ts +2 -1
  177. package/src/router/loader-resolution.ts +85 -4
  178. package/src/router/match-api.ts +36 -1
  179. package/src/router/match-middleware/background-revalidation.ts +16 -4
  180. package/src/router/match-middleware/cache-lookup.ts +35 -7
  181. package/src/router/match-middleware/cache-store.ts +15 -1
  182. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  183. package/src/router/match-result.ts +14 -2
  184. package/src/router/middleware-types.ts +16 -0
  185. package/src/router/middleware.ts +127 -49
  186. package/src/router/navigation-snapshot.ts +2 -1
  187. package/src/router/revalidation.ts +1 -1
  188. package/src/router/route-trie-builder.ts +28 -1
  189. package/src/router/segment-resolution/fresh.ts +22 -18
  190. package/src/router/segment-resolution/loader-cache.ts +2 -1
  191. package/src/router/segment-resolution/loader-mask.ts +5 -3
  192. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  193. package/src/router/segment-resolution/mask-nested.ts +8 -4
  194. package/src/router/segment-resolution/prefetch-deferral.ts +583 -0
  195. package/src/router/segment-resolution/revalidation.ts +215 -82
  196. package/src/rsc/full-payload.ts +37 -5
  197. package/src/rsc/handler.ts +82 -55
  198. package/src/rsc/helpers.ts +24 -0
  199. package/src/rsc/loader-fetch.ts +79 -24
  200. package/src/rsc/response-cache-serve.ts +18 -2
  201. package/src/rsc/response-route-handler.ts +4 -7
  202. package/src/rsc/rewrite-passes.ts +203 -0
  203. package/src/rsc/rsc-rendering.ts +54 -9
  204. package/src/rsc/shell-capture.ts +2 -1
  205. package/src/rsc/ssr-setup.ts +11 -5
  206. package/src/segment-loader-promise.ts +23 -20
  207. package/src/segment-system.tsx +60 -26
  208. package/src/server/context.ts +39 -5
  209. package/src/server/cookie-parse.ts +36 -12
  210. package/src/server/cookie-store.ts +7 -3
  211. package/src/server/handle-store.ts +152 -25
  212. package/src/server/request-context.ts +175 -64
  213. package/src/server/request-headers.ts +4 -14
  214. package/src/server/rewrite-trail.ts +190 -0
  215. package/src/server/slot-accessors.ts +57 -0
  216. package/src/server.ts +4 -0
  217. package/src/ssr/index.tsx +118 -16
  218. package/src/testing/action-form.ts +2 -11
  219. package/src/testing/dispatch.ts +138 -67
  220. package/src/testing/e2e/page-helpers.ts +3 -1
  221. package/src/testing/flight.entry.ts +1 -0
  222. package/src/testing/index.ts +7 -3
  223. package/src/testing/internal/action-id.ts +16 -1
  224. package/src/testing/internal/client-urls-projection.ts +70 -0
  225. package/src/testing/internal/test-action.ts +25 -0
  226. package/src/testing/render-route.tsx +16 -3
  227. package/src/testing/run-loader.ts +4 -1
  228. package/src/testing/run-middleware.ts +56 -11
  229. package/src/testing/run-revalidate.ts +142 -0
  230. package/src/testing/run-transition-when.ts +7 -8
  231. package/src/testing/serve-shell-request.ts +131 -8
  232. package/src/testing/vitest.ts +67 -1
  233. package/src/thenable-status.ts +11 -0
  234. package/src/types/boundaries.ts +12 -0
  235. package/src/types/handler-context.ts +14 -0
  236. package/src/types/loader-types.ts +30 -0
  237. package/src/types/request-scope.ts +8 -0
  238. package/src/types/segments.ts +26 -4
  239. package/src/urls/path-helper-types.ts +27 -5
  240. package/src/urls/type-extraction.ts +30 -10
  241. package/src/use-loader.tsx +18 -13
  242. package/src/vite/discovery/client-urls-projection.ts +20 -21
  243. package/src/vite/discovery/discover-routers.ts +94 -26
  244. package/src/vite/discovery/state.ts +28 -4
  245. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  246. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  247. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  248. package/src/vite/plugins/hoist/literal.ts +417 -0
  249. package/src/vite/plugins/hoist/scope.ts +370 -0
  250. package/src/vite/plugins/hoist/source.ts +84 -0
  251. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  252. package/src/vite/rango.ts +2 -0
  253. package/src/vite/router-discovery.ts +116 -53
  254. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  255. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  256. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  257. package/src/client-urls/revalidate-chain.ts +0 -83
  258. package/src/client-urls/revalidation-protocol.ts +0 -56
  259. package/src/testing/run-client-revalidate.ts +0 -92
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The pass loop that honors `throw rewrite()` (route-definition/rewrite.ts):
3
+ * one catch around the global middleware chain moves the same request
4
+ * context to the target (server/request-context.ts rebaseRequestContext) and
5
+ * runs the request again from the top, as a direct visit to the target
6
+ * would run it. Shared by the request handler (rsc/handler.ts) and the
7
+ * dispatch testing primitive, so the two cannot drift.
8
+ */
9
+
10
+ import { RewriteLoopError } from "../errors.js";
11
+ import {
12
+ describeRewriteTarget,
13
+ isRewrite,
14
+ rewriteNotSupported,
15
+ type Rewrite,
16
+ } from "../route-definition/rewrite.js";
17
+ import { prefixBasename } from "../router/basename.js";
18
+ import { createReverseFunction } from "../router/handler-context.js";
19
+ import { stripInternalParams } from "../router/internal-params.js";
20
+ import {
21
+ rebaseRequestContext,
22
+ type RequestContext,
23
+ } from "../server/request-context.js";
24
+ import { recordRewriteHop } from "../server/rewrite-trail.js";
25
+
26
+ /** Response header naming the route path a rewritten request rendered. */
27
+ export const REWRITE_HEADER = "x-rango-rewrite";
28
+
29
+ /**
30
+ * Resolve a rewrite's target against the router's route map (global names,
31
+ * no param auto-fill, as middleware `ctx.reverse`) and the request origin.
32
+ * A path is basename-prefixed like `redirect()` and must stay under the
33
+ * basename once normalized (`/../admin` cannot step out of it). The
34
+ * target's own `_rsc*` params are dropped: only the visitor's transport
35
+ * params carry over. Throws for an unknown name, a missing param, another
36
+ * origin or a path outside the basename.
37
+ */
38
+ export function resolveRewriteTarget(
39
+ rewrite: Rewrite,
40
+ routeMap: Record<string, string>,
41
+ base: URL,
42
+ basename?: string,
43
+ ): URL {
44
+ const { target } = rewrite;
45
+ const path =
46
+ "path" in target
47
+ ? 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)(
51
+ target.name,
52
+ target.params ?? {},
53
+ target.search,
54
+ );
55
+ const resolved = stripInternalParams(new URL(path, base.origin));
56
+ if (resolved.origin !== base.origin) {
57
+ throw new TypeError(
58
+ `${describeRewriteTarget(target)} must name a path in this app, not another origin.`,
59
+ );
60
+ }
61
+ if (
62
+ basename &&
63
+ resolved.pathname !== basename &&
64
+ !resolved.pathname.startsWith(basename + "/")
65
+ ) {
66
+ throw new TypeError(
67
+ `${describeRewriteTarget(target)} resolves to ${resolved.pathname}, outside the router's basename ${basename}.`,
68
+ );
69
+ }
70
+ resolved.hash = "";
71
+ return resolved;
72
+ }
73
+
74
+ /**
75
+ * The raw URL of a rewritten pass: the target's path and search plus the
76
+ * visitor's internal `_rsc*` transport params, so a navigation stays a
77
+ * navigation (same segments, version, partial flag).
78
+ */
79
+ function rewriteTransportUrl(target: URL, visitorRaw: URL): URL {
80
+ const raw = new URL(target);
81
+ visitorRaw.searchParams.forEach((value, key) => {
82
+ if (key.startsWith("_rsc")) raw.searchParams.append(key, value);
83
+ });
84
+ return raw;
85
+ }
86
+
87
+ /** The next pass of a rewritten request. */
88
+ export interface RewritePass {
89
+ request: Request;
90
+ /** The pass's raw URL, `_rsc*` params included. */
91
+ url: URL;
92
+ /** The target's clean URL. */
93
+ target: URL;
94
+ hops: number;
95
+ }
96
+
97
+ /**
98
+ * Move the request context to `rewrite`'s target for the next pass: check
99
+ * 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.
102
+ */
103
+ export function applyRewrite(
104
+ rewrite: Rewrite,
105
+ passRequest: Request,
106
+ reqCtx: RequestContext<any>,
107
+ routeMap: Record<string, string>,
108
+ basename: string | undefined,
109
+ ): RewritePass {
110
+ // A loader fetch first: `load()` with a body is a POST, and its refusal
111
+ // is the loader fetch's.
112
+ if (reqCtx.originalUrl.searchParams.has("_rsc_loader")) {
113
+ throw rewriteNotSupported(rewrite, "loader-fetch");
114
+ }
115
+ if (passRequest.method !== "GET" && passRequest.method !== "HEAD") {
116
+ throw rewriteNotSupported(rewrite, "mutation");
117
+ }
118
+ const target = resolveRewriteTarget(rewrite, routeMap, reqCtx.url, basename);
119
+ const hops = recordRewriteHop(reqCtx, reqCtx.url, target);
120
+ const url = rewriteTransportUrl(target, reqCtx.originalUrl);
121
+ const request = new Request(url, passRequest);
122
+ rebaseRequestContext(reqCtx, request, url);
123
+ return { request, url, target, hops };
124
+ }
125
+
126
+ /** The response to a rewrite loop. */
127
+ export function loopDetectedResponse(): Response {
128
+ return new Response("Loop Detected", {
129
+ status: 508,
130
+ headers: { "content-type": "text/plain;charset=utf-8" },
131
+ });
132
+ }
133
+
134
+ export interface RewritePassesOptions {
135
+ reqCtx: RequestContext<any>;
136
+ /** The visitor's request and raw URL: the first pass. */
137
+ request: Request;
138
+ url: URL;
139
+ routeMap: () => Record<string, string>;
140
+ basename: string | undefined;
141
+ /** One pass: the global chain matched on `url`'s path around the core handler. */
142
+ runPass: (request: Request, url: URL) => Promise<Response>;
143
+ /** After a hop moved the context to its target. */
144
+ onHop?: (pass: RewritePass) => void;
145
+ /** Report a rewrite the request cannot take (onError, phase "routing"). */
146
+ report: (error: unknown, at: { request: Request; url: URL }) => void;
147
+ /**
148
+ * The response for a refused rewrite other than a loop (a loader fetch's
149
+ * error response); undefined rethrows the error.
150
+ */
151
+ refuse?: (error: unknown, rewrite: Rewrite) => Response | undefined;
152
+ }
153
+
154
+ export interface RewritePassesResult {
155
+ response: Response;
156
+ /** The pathname the final pass rendered, when a rewrite took the request there. */
157
+ rewrittenTo?: string;
158
+ }
159
+
160
+ /**
161
+ * Run the request pass by pass until one produces a response. A `Rewrite`
162
+ * that unwinds a pass moves the context to its target and runs the next;
163
+ * a loop answers 508; a rewrite the request cannot take is reported and
164
+ * becomes `refuse`'s response, or is thrown.
165
+ */
166
+ export async function runRewritePasses(
167
+ opts: RewritePassesOptions,
168
+ ): Promise<RewritePassesResult> {
169
+ let request = opts.request;
170
+ let url = opts.url;
171
+ let rewrittenTo: string | undefined;
172
+ for (;;) {
173
+ let rewrite: Rewrite;
174
+ try {
175
+ return { response: await opts.runPass(request, url), rewrittenTo };
176
+ } catch (error) {
177
+ if (!isRewrite(error)) throw error;
178
+ rewrite = error;
179
+ }
180
+ let pass: RewritePass;
181
+ try {
182
+ pass = applyRewrite(
183
+ rewrite,
184
+ request,
185
+ opts.reqCtx,
186
+ opts.routeMap(),
187
+ opts.basename,
188
+ );
189
+ } catch (error) {
190
+ opts.report(error, { request, url });
191
+ if (error instanceof RewriteLoopError) {
192
+ return { response: loopDetectedResponse() };
193
+ }
194
+ const refused = opts.refuse?.(error, rewrite);
195
+ if (refused) return { response: refused };
196
+ throw error;
197
+ }
198
+ request = pass.request;
199
+ url = pass.url;
200
+ rewrittenTo = pass.target.pathname;
201
+ opts.onHop?.(pass);
202
+ }
203
+ }
@@ -51,7 +51,7 @@ import {
51
51
  import type { HandlerContext } from "./handler-context.js";
52
52
  import { attachTransitionWhen } from "./attach-transition-when.js";
53
53
  import { publicRouteName } from "../route-name.js";
54
- import { buildFullPayload } from "./full-payload.js";
54
+ import { buildFullPayload, renderedLocation } from "./full-payload.js";
55
55
  import {
56
56
  capShellWindow,
57
57
  lastStoredCaptureSeq,
@@ -94,6 +94,7 @@ import {
94
94
  type DevShellLookup,
95
95
  } from "./shell-build-manifest.js";
96
96
  import { contextGet } from "../context-var.js";
97
+ import { PREFETCH_HEADER } from "../prefetch-markers.js";
97
98
  import {
98
99
  isWarmReplace,
99
100
  noteWarmShellEvent,
@@ -302,7 +303,10 @@ type PreparedRender =
302
303
  | {
303
304
  kind: "payload";
304
305
  payload: RscPayload;
305
- /** The response depends on the source page: an intercept targets the route. */
306
+ /**
307
+ * The response depends on the source page: an intercept targets the
308
+ * route, or it carries deferred units (`prefetch: false`).
309
+ */
306
310
  sourceScoped: boolean;
307
311
  pprReplayStatus?: PprReplayStatus;
308
312
  /** The key a navigation-only heal capture stores under, when one is needed. */
@@ -831,11 +835,17 @@ function* preparePayloadPlan<TEnv>(
831
835
  };
832
836
  }
833
837
 
838
+ // What a prefetch defers depends on what the source page holds: a page
839
+ // that holds the segment gets it rendered. Adopted from another page, a
840
+ // deferring payload held the click on a fill for content a plain click
841
+ // showed at once, and ran held work again.
842
+ const deferring = result.segments.some((s) => s.deferred);
843
+ if (deferring) reqCtx._payloadDeferred = true;
834
844
  return {
835
845
  kind: "payload",
836
846
  payload: {
837
847
  metadata: {
838
- pathname: url.pathname,
848
+ pathname: renderedLocation(reqCtx, url).pathname,
839
849
  // routerId is serialized on every payload (including within-session
840
850
  // ones) so the frontend can read the current app/router identity. It
841
851
  // always equals the current app's id: a cross-app navigation is
@@ -860,7 +870,7 @@ function* preparePayloadPlan<TEnv>(
860
870
  stateCookieName: ctx.router.resolvedStateCookieName,
861
871
  },
862
872
  },
863
- sourceScoped: result.interceptTargeted === true,
873
+ sourceScoped: result.interceptTargeted === true || deferring,
864
874
  pprReplayStatus,
865
875
  healKey,
866
876
  };
@@ -1200,16 +1210,34 @@ function renderPreparedRscResponse<TEnv>(
1200
1210
  rscHeaders["x-rsc-prefetch-scope"] = "source";
1201
1211
  }
1202
1212
  // Enable browser HTTP caching for prefetch responses only.
1203
- // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
1204
- // a source-agnostic response (see sourceScoped above), and a configured
1205
- // cache-control value (false disables caching).
1206
- const isPrefetch = requestHeaders(request).has("X-Rango-Prefetch");
1207
- if (isPrefetch && isPartial && !sourceScoped) {
1213
+ // Requires a prefetch request (X-Rango-Prefetch, sent by Link prefetch
1214
+ // fetch), a source-agnostic response (see sourceScoped above), and a
1215
+ // configured cache-control value (false disables caching).
1216
+ if (reqCtx._requestKind === "prefetch" && isPartial && !sourceScoped) {
1208
1217
  const cc = ctx.router.prefetchCacheControl;
1209
1218
  if (cc) {
1210
1219
  rscHeaders["cache-control"] = cc;
1211
1220
  }
1212
1221
  }
1222
+ // prefetch: false (docs/design/prefetch-false.md, R7). A response that
1223
+ // carries deferred units answers one page's prefetch, and a fill is the
1224
+ // second half of one adoption: neither is reused by a shared cache or the
1225
+ // document cache (which also refuses a payload marked `_payloadDeferred`),
1226
+ // whatever Cache-Control the route set. A route with no flag keeps its
1227
+ // headers.
1228
+ if (
1229
+ reqCtx._payloadDeferred ||
1230
+ (isPartial && reqCtx._requestKind === "fill")
1231
+ ) {
1232
+ rscHeaders["cache-control"] = NOT_REUSED;
1233
+ }
1234
+ // The browser may still keep the body, and answers from it without asking
1235
+ // while it reloads a document for back/forward. A navigation is the same
1236
+ // URL as its prefetch: the header that tells them apart keeps the deferring
1237
+ // body from answering one.
1238
+ if (reqCtx._payloadDeferred) {
1239
+ rscHeaders.vary += `, ${PREFETCH_HEADER}`;
1240
+ }
1213
1241
 
1214
1242
  const isFlightResponse = isRscRequest(request, url, isPartial);
1215
1243
  const stageTracking = {
@@ -1694,6 +1722,21 @@ function publishTailTiming(
1694
1722
 
1695
1723
  const PENDING = Symbol("pending");
1696
1724
 
1725
+ /**
1726
+ * Cache-Control of a response that is not to be reused: one that carries
1727
+ * deferred units (`prefetch: false`), and a fill. `private` keeps it out of
1728
+ * shared caches; `no-cache` makes the browser ask again, and with no
1729
+ * validator on the response that is a full request. The browser may keep the
1730
+ * body all the same (Chromium does) and answers from it, without asking,
1731
+ * while it reloads a document for back/forward. That is harmless: the client
1732
+ * completes any payload that carries deferred work, wherever it came from.
1733
+ * Not `no-store`:
1734
+ * Chromium reports a `no-store` fetch whose body is read through a stream
1735
+ * as canceled (net::ERR_ABORTED in DevTools, and Playwright's
1736
+ * response.finished() never settles), though every byte arrived.
1737
+ */
1738
+ const NOT_REUSED = "private, no-cache";
1739
+
1697
1740
  /**
1698
1741
  * Whether `value` is not a promise, or a promise that has already settled:
1699
1742
  * racing it against an already-resolved promise, a settled one's reaction is
@@ -1824,6 +1867,8 @@ function serveShellHit(
1824
1867
  payload.metadata.initialTheme = entry.initialTheme as
1825
1868
  | import("../theme/types.js").Theme
1826
1869
  | undefined;
1870
+ // Same for the location: the capture rendered the target's.
1871
+ payload.metadata.pathname = url.pathname;
1827
1872
  }
1828
1873
  // Full Flight render per request: hydration needs the whole payload (there
1829
1874
  // is no Flight-side resume — a React limitation, not ours).
@@ -73,6 +73,7 @@ import {
73
73
  } from "../server/context.js";
74
74
  import { isThenable } from "../handles/is-thenable.js";
75
75
  import { requestHeaders } from "../server/request-headers.js";
76
+ import { PREFETCH_HEADER } from "../prefetch-markers.js";
76
77
  import type {
77
78
  CachedEntryData,
78
79
  ShellCacheEntry,
@@ -1448,7 +1449,7 @@ function createNavigationCaptureRequest(request: Request, url: URL): Request {
1448
1449
  headers.set("accept", "text/html");
1449
1450
  for (const name of [
1450
1451
  "rsc-action",
1451
- "x-rango-prefetch",
1452
+ PREFETCH_HEADER,
1452
1453
  "x-rango-state",
1453
1454
  "x-rsc-hmr",
1454
1455
  "x-rsc-router-client-path",
@@ -22,6 +22,7 @@ import {
22
22
  RSC_WIRE_MIME,
23
23
  } from "../router/content-negotiation.js";
24
24
  import { _getRequestContext } from "../server/request-context.js";
25
+ import { renderedLocation } from "./full-payload.js";
25
26
 
26
27
  export type SSRSetup = readonly [SSRModule, SSRStreamMode];
27
28
 
@@ -38,6 +39,8 @@ export const SSR_SETUP_VAR = "__ssrSetup";
38
39
  * lazily so stores created after kickoff are still captured). Without a
39
40
  * getter the promises run bare — no `.then()` microtasks, no
40
41
  * `performance.now()` calls — keeping the non-debug hot path lean.
42
+ * `ssrModule` reuses a load already started (a rewritten request's earlier
43
+ * pass).
41
44
  */
42
45
  export function startSSRSetup<TEnv>(
43
46
  ctx: HandlerContext<TEnv>,
@@ -45,16 +48,17 @@ export function startSSRSetup<TEnv>(
45
48
  env: TEnv,
46
49
  url: URL,
47
50
  getMetricsStore?: () => MetricsStore | undefined,
51
+ ssrModule?: Promise<SSRModule>,
48
52
  ): Promise<SSRSetup> {
49
53
  if (!getMetricsStore) {
50
54
  return Promise.all([
51
- ctx.loadSSRModule(),
55
+ ssrModule ?? ctx.loadSSRModule(),
52
56
  ctx.resolveStreamMode(request, env, url),
53
57
  ]);
54
58
  }
55
59
  const start = performance.now();
56
60
  return Promise.all([
57
- ctx.loadSSRModule().then((mod) => {
61
+ (ssrModule ?? ctx.loadSSRModule()).then((mod) => {
58
62
  appendMetric(
59
63
  getMetricsStore(),
60
64
  "ssr:module-load",
@@ -110,7 +114,9 @@ export function createSsrHtmlStage<TEnv>(
110
114
  options.url,
111
115
  options.metricsStore,
112
116
  );
113
- const renderErrors = _getRequestContext()?._renderErrors;
117
+ const reqCtx = _getRequestContext();
118
+ const renderErrors = reqCtx?._renderErrors;
119
+ const location = renderedLocation(reqCtx, options.url);
114
120
  return {
115
121
  render: () =>
116
122
  // search: the LIVE request's query string, threaded out-of-band into
@@ -121,11 +127,11 @@ export function createSsrHtmlStage<TEnv>(
121
127
  ssrModule.renderHTML(flight.stream, {
122
128
  ...options.render,
123
129
  streamMode,
124
- search: options.url.search,
130
+ search: location.search,
125
131
  // origin: same out-of-band channel — seeds the SSR store location
126
132
  // so origin-dependent markup (Link's data-external) agrees with the
127
133
  // browser's window.location across hydration.
128
- origin: options.url.origin,
134
+ origin: location.origin,
129
135
  // A component that throws inside a Suspense boundary leaves an
130
136
  // errored boundary in a completed document; the document cache
131
137
  // reads the list before storing it.
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
2
  import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
3
+ import { asStream } from "./browser/defer.js";
3
4
 
4
5
  /**
5
6
  * Browser: a boundary is handed the settled value. React treats a promise as
@@ -7,7 +8,13 @@ import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
7
8
  * promise it has not read, in a render that cannot wait, shows its fallback.
8
9
  * So a cache entry records its array once the aggregate fulfils and later
9
10
  * calls return that same array; until then (and when it rejects) they return
10
- * the promise. A boundary with no loaders gets one shared empty array.
11
+ * the promise. A boundary with no loaders gets one shared empty array. An
12
+ * aggregate whose sources are all streams that have fulfilled (React's
13
+ * `status` on the thenable, which Flight sets itself; a plain value is not a
14
+ * stream, whatever fields it carries) is known on its first
15
+ * call and is the array from the start: the sources of a page whose entry was
16
+ * rewritten without a render (browser/partial-update.ts fillHoles) are
17
+ * first aggregated by the next navigation that keeps them.
11
18
  *
12
19
  * Server: a fresh promise per call, so Suspense emits the fallback in the
13
20
  * streamed HTML. A shared resolved promise would carry React's `.status`
@@ -22,10 +29,10 @@ const IS_BROWSER = typeof window !== "undefined";
22
29
 
23
30
  interface LoaderCacheEntry {
24
31
  sources: any[];
25
- // Resolves to the same array it records in `value`; a rejection passes
26
- // through untouched.
27
- promise: Promise<any[]>;
28
- value?: any[];
32
+ // The aggregate while it is pending (a rejection passes through untouched),
33
+ // then the array it fulfilled with. The array from the start for sources
34
+ // that have all fulfilled.
35
+ result: Promise<any[]> | any[];
29
36
  }
30
37
 
31
38
  // Cap the per-key entries array. A stable first-ref (e.g. a layout loader whose
@@ -91,13 +98,7 @@ export function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
91
98
  }
92
99
  }
93
100
  }
94
- return Promise.all(
95
- loaders.map((loader) =>
96
- loader.loaderData instanceof Promise
97
- ? loader.loaderData
98
- : Promise.resolve(loader.loaderData),
99
- ),
100
- );
101
+ return Promise.all(loaders.map((loader) => asStream(loader.loaderData)));
101
102
  }
102
103
 
103
104
  function isObjectLike(value: unknown): value is object {
@@ -131,17 +132,19 @@ export function getMemoizedLoaderPromise(
131
132
  if (entries) {
132
133
  for (const entry of entries) {
133
134
  if (hasSameReferences(entry.sources, sources)) {
134
- return entry.value ?? entry.promise;
135
+ return entry.result;
135
136
  }
136
137
  }
137
138
  }
138
139
 
139
- const newEntry = { sources } as LoaderCacheEntry;
140
- const promise = buildLoaderPromise(loaders).then((values) => {
141
- newEntry.value = values;
142
- return values;
143
- });
144
- newEntry.promise = promise;
140
+ const newEntry: LoaderCacheEntry = { sources, result: [] };
141
+ newEntry.result = sources.every(
142
+ (source) =>
143
+ source instanceof Promise &&
144
+ (source as { status?: string }).status === "fulfilled",
145
+ )
146
+ ? sources.map((source) => source.value)
147
+ : buildLoaderPromise(loaders).then((values) => (newEntry.result = values));
145
148
  if (entries) {
146
149
  // Bound the array: drop the oldest entry before appending when at the cap.
147
150
  if (entries.length >= MAX_ENTRIES_PER_KEY) {
@@ -153,5 +156,5 @@ export function getMemoizedLoaderPromise(
153
156
  } else {
154
157
  primitiveLoaderCache.set(first, [newEntry]);
155
158
  }
156
- return promise;
159
+ return newEntry.result;
157
160
  }
@@ -9,6 +9,8 @@ import { AuditedOutletProvider, OutletProvider } from "./outlet-provider.js";
9
9
  import { withOptimisticCommitNone } from "./browser/optimistic-commit.js";
10
10
  import { MountContextProvider } from "./browser/react/mount-context.js";
11
11
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
12
+ import { isRenderableLoading } from "./types/boundaries.js";
13
+ import { isPlaceholder } from "./types/segments.js";
12
14
  import { decodeLoaderResults } from "./decode-loader-results.js";
13
15
  import { LoaderRedirect } from "./loader-redirect.js";
14
16
  import { invariant } from "./errors.js";
@@ -23,6 +25,7 @@ import { RootErrorBoundary } from "./root-error-boundary.js";
23
25
  import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
24
26
  import { INTERNAL_RANGO_SUSPENSE_AUDIT } from "./internal-suspense-audit.js";
25
27
  import { getBoundaryContent } from "./segment-boundary-content.js";
28
+ import { asStream } from "./browser/defer.js";
26
29
  import {
27
30
  buildLoaderPromise,
28
31
  getMemoizedLoaderPromise,
@@ -61,15 +64,6 @@ function segDebugLog(msg: string, details?: Record<string, unknown>): void {
61
64
  const ReactViewTransition: any =
62
65
  "ViewTransition" in React ? (React as any).ViewTransition : null;
63
66
 
64
- // A loading skeleton is renderable only when it is a real ReactNode value.
65
- // `false` is treated as "not renderable" here. This is the three-term gate;
66
- // the distinct two-term gate at the LoaderBoundary site deliberately treats
67
- // `false` as "create a boundary without a RouteContentWrapper"
68
- // (tree-structure.md), so it must NOT use this helper.
69
- function isRenderableLoading(loading: ReactNode): boolean {
70
- return loading !== undefined && loading !== null && loading !== false;
71
- }
72
-
73
67
  // Exported for unit testing the no-parallel fast path (D6); internal otherwise.
74
68
  export function restoreParallelLoaderMarkers(
75
69
  segments: ResolvedSegment[],
@@ -264,32 +258,61 @@ function wrapDefaultOutletContent(
264
258
  * reader sits above every boundary, so the throw is a Fizz shell error (500).
265
259
  * `settledFallback` is that node; the caller plants it and, for a redirect,
266
260
  * replaces the whole page (an ancestor reading the loader would still throw).
261
+ *
262
+ * `settle` is the forceAwait/action lane of a loader set that holds a
263
+ * deferred loader (`prefetch: false`, see hasDeferred): every other loader is
264
+ * settled like a flagged one, so the commit stays whole for them, and the
265
+ * deferred one keeps its hole for the read site to suspend on. A settled
266
+ * error with no boundary is left to the read site there, where the aggregate
267
+ * lane it replaces would have thrown it.
267
268
  */
268
- async function buildLoaderStreams(loaders: ResolvedSegment[]): Promise<{
269
+ async function buildLoaderStreams(
270
+ loaders: ResolvedSegment[],
271
+ settle?: boolean,
272
+ ): Promise<{
269
273
  streams: Record<string, unknown>;
270
274
  awaitedIds: string[] | undefined;
271
275
  settledFallback: ReactNode | undefined;
272
276
  }> {
273
277
  const streams: Record<string, unknown> = {};
274
278
  let awaitedIds: string[] | undefined;
275
- let awaitedValues: unknown[] | undefined;
279
+ let settledIds: string[] | undefined;
280
+ let settledValues: unknown[] | undefined;
276
281
  for (const l of loaders) {
277
- if (l.awaitBeforeFlush) {
282
+ if (l.awaitBeforeFlush || (settle && !isPlaceholder(l))) {
278
283
  const value = await l.loaderData;
279
284
  streams[l.loaderId!] = value;
280
- (awaitedIds ??= []).push(l.loaderId!);
281
- (awaitedValues ??= []).push(value);
285
+ if (l.awaitBeforeFlush) (awaitedIds ??= []).push(l.loaderId!);
286
+ (settledIds ??= []).push(l.loaderId!);
287
+ (settledValues ??= []).push(value);
282
288
  } else {
283
289
  streams[l.loaderId!] = l.loaderData;
284
290
  }
285
291
  }
286
- const settledFallback = awaitedIds
287
- ? (decodeLoaderResults(awaitedValues!, awaitedIds).errorFallback ??
288
- undefined)
289
- : undefined;
292
+ let settledFallback: ReactNode | undefined;
293
+ if (settledIds) {
294
+ try {
295
+ settledFallback =
296
+ decodeLoaderResults(settledValues!, settledIds).errorFallback ??
297
+ undefined;
298
+ } catch (error) {
299
+ if (!settle) throw error;
300
+ }
301
+ }
290
302
  return { streams, awaitedIds, settledFallback };
291
303
  }
292
304
 
305
+ /**
306
+ * A loader set that holds a deferred loader (`prefetch: false`): its
307
+ * `loaderData` is a hole (browser/defer.ts) the browser resolves when the
308
+ * fill request lands (browser/partial-update.ts fillHoles), so no lane may
309
+ * await the set as a whole. The
310
+ * tree build would wait for a request that is only sent once it returns.
311
+ */
312
+ function hasDeferred(loaders: ResolvedSegment[]): boolean {
313
+ return loaders.some(isPlaceholder);
314
+ }
315
+
293
316
  function isLoaderRedirectNode(node: ReactNode): boolean {
294
317
  return isValidElement(node) && node.type === LoaderRedirect;
295
318
  }
@@ -354,6 +377,11 @@ export async function renderSegments(
354
377
  rootLayout: RootLayout,
355
378
  transitionGatedOff,
356
379
  } = options || {};
380
+ // forceAwait and action lanes settle loader data before the commit, except
381
+ // a set that holds a deferred loader (hasDeferred).
382
+ const settleLoaders = !!(forceAwait || isAction);
383
+ const settleAll = (loaders: ResolvedSegment[]): boolean =>
384
+ settleLoaders && !hasDeferred(loaders);
357
385
 
358
386
  const segDebug = INTERNAL_RANGO_DEBUG;
359
387
  const segDebugStart = segDebug ? performance.now() : 0;
@@ -484,6 +512,8 @@ export async function renderSegments(
484
512
  (loader) => loader.loaderId && loader.loaderData !== undefined,
485
513
  );
486
514
 
515
+ // A deferred unit's component is a hole (browser/defer.ts), never a
516
+ // Promise: the awaits below pass it through.
487
517
  let resolvedComponent = component;
488
518
  if (isAction && component instanceof Promise) {
489
519
  const componentAwaitStart = segDebug ? performance.now() : 0;
@@ -540,7 +570,11 @@ export async function renderSegments(
540
570
  componentTypeof: typeof c,
541
571
  });
542
572
  }
543
- nodeContent = registerLazyRef(resolvedComponent);
573
+ // A hole here is read by React itself (browser/defer.ts asStream).
574
+ const streamed = asStream(resolvedComponent) as ReactNode;
575
+ nodeContent = isPlaceholder(node.segment)
576
+ ? streamed
577
+ : registerLazyRef(streamed);
544
578
  }
545
579
 
546
580
  // Wrap with <ViewTransition> if transition config exists (React 19.3+ / experimental).
@@ -614,7 +648,7 @@ export async function renderSegments(
614
648
  // for content-suspending routes (segment-loader-promise.ts).
615
649
  let boundaryLoaderStreams: Record<string, unknown> | undefined;
616
650
  let boundaryAwaitedLoaderIds: string[] | undefined;
617
- if (forceAwait || isAction) {
651
+ if (settleAll(loaderEntries)) {
618
652
  const awaitStart = segDebug ? performance.now() : 0;
619
653
  boundaryLoaderData =
620
654
  loaderDataPromise instanceof Promise
@@ -632,7 +666,7 @@ export async function renderSegments(
632
666
  streams: boundaryLoaderStreams,
633
667
  awaitedIds: boundaryAwaitedLoaderIds,
634
668
  settledFallback,
635
- } = await buildLoaderStreams(loaderEntries));
669
+ } = await buildLoaderStreams(loaderEntries, settleLoaders));
636
670
  if (settledFallback !== undefined) {
637
671
  if (isLoaderRedirectNode(settledFallback)) {
638
672
  content = settledFallback;
@@ -698,7 +732,7 @@ export async function renderSegments(
698
732
  let errorFallback: ReactNode = null;
699
733
  let loaderStreams: Record<string, unknown> | undefined;
700
734
  let awaitedLoaderIds: string[] | undefined;
701
- if (forceAwait || isAction) {
735
+ if (settleAll(layoutLoaders)) {
702
736
  const layoutAwaitStart = segDebug ? performance.now() : 0;
703
737
  const resolvedData = await buildLoaderPromise(layoutLoaders);
704
738
  if (segDebug) {
@@ -726,7 +760,7 @@ export async function renderSegments(
726
760
  streams: loaderStreams,
727
761
  awaitedIds: awaitedLoaderIds,
728
762
  settledFallback,
729
- } = await buildLoaderStreams(layoutLoaders));
763
+ } = await buildLoaderStreams(layoutLoaders, settleLoaders));
730
764
  if (settledFallback !== undefined) {
731
765
  if (isLoaderRedirectNode(settledFallback)) {
732
766
  content = settledFallback;
@@ -780,9 +814,9 @@ export async function renderSegments(
780
814
  // loader paid the pre-flush await, settle the aggregate so
781
815
  // LoaderResolver decodes the array without a Flight-chunk use().
782
816
  const settleParallelAggregate =
783
- forceAwait ||
784
- isAction ||
785
- ownedLoaders.every((l) => l.awaitBeforeFlush === true);
817
+ (settleLoaders ||
818
+ ownedLoaders.every((l) => l.awaitBeforeFlush === true)) &&
819
+ !hasDeferred(ownedLoaders);
786
820
  if (settleParallelAggregate) {
787
821
  const parallelAwaitStart = segDebug ? performance.now() : 0;
788
822
  p.loaderDataPromise =