@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
@@ -23,7 +23,7 @@ import {
23
23
  serializeStateCookie,
24
24
  } from "../browser/cookie-name.js";
25
25
  import type { LoaderDefinition, LoaderContext } from "../types.js";
26
- import type { ScopedReverseFunction } from "../reverse.js";
26
+ import type { AutofillAwareReverseFunction } from "../types/handler-context.js";
27
27
  import type {
28
28
  DefaultEnv,
29
29
  DefaultReverseRouteMap,
@@ -65,6 +65,8 @@ import {
65
65
  } from "../theme/constants.js";
66
66
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
67
67
  import type { RoutineTrace } from "../rsc/routine-plan.js";
68
+ import type { ActionContinuation } from "../rsc/server-action.js";
69
+ import type { FormActionOutcome } from "../rsc/progressive-enhancement.js";
68
70
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
69
71
  import {
70
72
  assertCachedHeaderWriteAllowed,
@@ -345,6 +347,13 @@ export interface RequestContext<
345
347
  */
346
348
  _shellCaptureRun?: boolean;
347
349
 
350
+ /**
351
+ * @internal Set beside `_shellCaptureRun` when the shell is keyed by the
352
+ * visitor's URL (`rewrite(...).shell("visitor")`): the capture renders the
353
+ * visitor's location and `ctx.rewrittenFrom`; the theme stays the default.
354
+ */
355
+ _shellCaptureForVisitor?: boolean;
356
+
348
357
  /**
349
358
  * @internal Bake-lane loader containers collected DURING a shell capture:
350
359
  * segment-key -> the loader's (pre-wrap) result promise. Populated by
@@ -774,8 +783,15 @@ export interface RequestContext<
774
783
  * Generate URLs from route names.
775
784
  * Uses the global route map. After route matching, scoped (`.name`) resolution
776
785
  * works within the matched include() scope.
777
- */
778
- reverse: ScopedReverseFunction<
786
+ *
787
+ * Params are optional in the type because the fill depends on the phase.
788
+ * After the route match (route middleware, handlers, loaders, server actions)
789
+ * missing params come from the matched route's params; explicit params win.
790
+ * In global middleware before `next()` nothing is matched and the fill is
791
+ * empty, so a route with a required param throws `Missing param "<name>" for
792
+ * route "<route>"` at runtime (a param-less route still resolves).
793
+ */
794
+ reverse: AutofillAwareReverseFunction<
779
795
  Record<string, string>,
780
796
  DefaultReverseRouteMap
781
797
  >;
@@ -786,6 +802,13 @@ export interface RequestContext<
786
802
  /** @internal Previous route key (from the navigation source), used for revalidation */
787
803
  _prevRouteKey?: string;
788
804
 
805
+ /**
806
+ * @internal Leaving a rewritten page, the visitor's URL moved
807
+ * (router/match-api.ts): the default revalidation counts it as a search
808
+ * change, since the render may read `ctx.rewrittenFrom`.
809
+ */
810
+ _visitorMoved?: boolean;
811
+
789
812
  /**
790
813
  * @internal True while the post-action revalidation render is running (set by
791
814
  * revalidateAfterAction). The "use cache" runtime reads this to prefer
@@ -796,6 +819,19 @@ export interface RequestContext<
796
819
  */
797
820
  _inActionRevalidation?: boolean;
798
821
 
822
+ /**
823
+ * @internal The outcome of the action this request ran (a JS server
824
+ * action's continuation, a no-JS form post's form state), set before route
825
+ * middleware wraps the re-render. While it is unset, applyRewrite
826
+ * (rsc/rewrite-passes.ts) moves a mutation with its body, so the action
827
+ * runs on the target's pass, or refuses it (`disallowMutations()`, or a
828
+ * body middleware read); once set, the request moves to the target, whose
829
+ * pass re-renders from it instead of running the action again (#1145).
830
+ */
831
+ _actionRan?:
832
+ | { kind: "action"; continuation: ActionContinuation }
833
+ | { kind: "form"; outcome: FormActionOutcome };
834
+
799
835
  /**
800
836
  * @internal Render barrier for experimental `rendered()` API.
801
837
  * Resolves when all non-loader segments have settled and handle data
@@ -1017,6 +1053,7 @@ export type PublicRequestContext<
1017
1053
  | "_onDemandProducer"
1018
1054
  | "_searchParamsFilter"
1019
1055
  | "_shellCaptureRun"
1056
+ | "_shellCaptureForVisitor"
1020
1057
  | "_shellImplicitCache"
1021
1058
  | "_shellLoaderSeed"
1022
1059
  | "_shellCaptureLoaderRecords"
@@ -1048,7 +1085,9 @@ export type PublicRequestContext<
1048
1085
  | "_locationState"
1049
1086
  | "_routeName"
1050
1087
  | "_prevRouteKey"
1088
+ | "_visitorMoved"
1051
1089
  | "_inActionRevalidation"
1090
+ | "_actionRan"
1052
1091
  | "_reportedErrors"
1053
1092
  | "_renderErrors"
1054
1093
  | "_recoveredHandlerErrors"
@@ -45,6 +45,38 @@ export function requestHeaders(request: Request): Headers {
45
45
  return RAW_HEADERS.get(request) ?? request.headers;
46
46
  }
47
47
 
48
+ /**
49
+ * A copy of `request`'s headers for a bodyless mirror of it (the PE
50
+ * re-render, route middleware around it, a rewrite pass after an action),
51
+ * without the body-framing ones and `content-type`, so a body check gated on
52
+ * the content-type passes over a request that has no body.
53
+ */
54
+ export function bodylessHeaders(request: Request): Headers {
55
+ const headers = new Headers(requestHeaders(request));
56
+ headers.delete("content-length");
57
+ headers.delete("content-encoding");
58
+ headers.delete("transfer-encoding");
59
+ headers.delete("content-type");
60
+ return headers;
61
+ }
62
+
63
+ /**
64
+ * A bodyless mirror of `request` at `url`: `method`, its
65
+ * {@link bodylessHeaders}, the signal and workerd's `cf`.
66
+ */
67
+ export function bodylessRequest(
68
+ url: string | URL,
69
+ request: Request,
70
+ method: string,
71
+ ): Request {
72
+ return new Request(url, {
73
+ method,
74
+ headers: bodylessHeaders(request),
75
+ signal: request.signal,
76
+ cf: (request as { cf?: unknown }).cf,
77
+ } as RequestInit);
78
+ }
79
+
48
80
  /**
49
81
  * Shadow `request.headers` with an own, non-enumerable getter that calls
50
82
  * `beforeRead` and returns the request's own Headers (stable per request, so
@@ -11,8 +11,15 @@
11
11
  import { contextGet, contextSet, createVar } from "../context-var.js";
12
12
  import { RewriteLoopError } from "../errors.js";
13
13
  import { documentCacheStoresRender } from "../cache/document-cache-policy.js";
14
- import { guardIdentityRead, type IdentityReadWording } from "./context.js";
14
+ import {
15
+ guardIdentityRead,
16
+ recordLoaderIdentityRead,
17
+ refuseInCacheScope,
18
+ type IdentityReadWording,
19
+ } from "./context.js";
20
+ import { isInsideIdentityExempt } from "../cache/cache-exec-scope.js";
15
21
  import { stripInternalParams } from "../router/internal-params.js";
22
+ import type { RewriteShell } from "../route-definition/rewrite.js";
16
23
  import type { RequestContext } from "./request-context.js";
17
24
 
18
25
  /** Most rewrites one request may take; the next one is a RewriteLoopError. */
@@ -23,7 +30,8 @@ const REWRITE_TRAIL = createVar<readonly string[]>();
23
30
 
24
31
  type TrailContext = Pick<RequestContext<any>, "_variables">;
25
32
 
26
- function pathKey(url: URL): string {
33
+ /** A URL's path and search: what a rewrite hop, a target and a visitor's move compare. */
34
+ export function pathKey(url: URL): string {
27
35
  return url.pathname + url.search;
28
36
  }
29
37
 
@@ -60,6 +68,25 @@ export function recordRewriteHop(
60
68
  return hops;
61
69
  }
62
70
 
71
+ /** The `.shell()` choice of the hop that reached the route that renders. */
72
+ const REWRITE_SHELL = createVar<RewriteShell | undefined>();
73
+
74
+ export function recordRewriteShell(
75
+ reqCtx: TrailContext,
76
+ audience: RewriteShell | undefined,
77
+ ): void {
78
+ contextSet(reqCtx._variables, REWRITE_SHELL, audience);
79
+ }
80
+
81
+ /** The last hop's `rewrite(...).shell()` choice; undefined: none. */
82
+ export function rewriteShellAudience(
83
+ reqCtx: TrailContext | undefined,
84
+ ): RewriteShell | undefined {
85
+ return reqCtx?._variables
86
+ ? (contextGet(reqCtx._variables, REWRITE_SHELL) as RewriteShell | undefined)
87
+ : undefined;
88
+ }
89
+
63
90
  type AudienceContext = Pick<
64
91
  RequestContext<any>,
65
92
  "_shellCaptureRun" | "_documentCacheRender" | "res"
@@ -68,7 +95,8 @@ type AudienceContext = Pick<
68
95
  /**
69
96
  * A render other visitors may be served: a PPR shell capture, or a render
70
97
  * the document cache stores (its response opted in before next()). It shows
71
- * what a direct visit to its URL shows: no visitor theme, no visitor URL.
98
+ * no visitor theme, and no visitor URL unless it is a visitor-keyed shell
99
+ * capture (`_shellCaptureForVisitor`).
72
100
  */
73
101
  export function rendersForSharedAudience(reqCtx: AudienceContext): boolean {
74
102
  return reqCtx._shellCaptureRun === true || documentCacheStoresRender(reqCtx);
@@ -112,9 +140,9 @@ export type RewrittenFromReader = "render" | "middleware";
112
140
  * The read behind every `ctx.rewrittenFrom`. Guarded like cookies(): inside
113
141
  * a `cache()` boundary or a `"use cache"` function it throws, and a loader's
114
142
  * own `cache()` fill records it. A render other visitors may be served sees
115
- * `undefined`, as on a direct visit; any other render that sees the visitor's
116
- * URL marks `_payloadVisitorLocation`, so the document cache does not store
117
- * it.
143
+ * `undefined`, as on a direct visit, except a visitor-keyed shell capture,
144
+ * whose key covers the URL; any other render that sees the visitor's URL
145
+ * marks `_payloadVisitorLocation`, so the document cache does not store it.
118
146
  */
119
147
  function readRewrittenFrom(
120
148
  reqCtx: RequestContext<any> | undefined,
@@ -122,7 +150,15 @@ function readRewrittenFrom(
122
150
  ): string | undefined {
123
151
  if (!reqCtx) return undefined;
124
152
  if (reader === "render" && rendersForSharedAudience(reqCtx)) {
125
- return undefined;
153
+ if (!reqCtx._shellCaptureForVisitor) return undefined;
154
+ // guardIdentityRead minus the capture guard: a cache() boundary, a "use
155
+ // cache" body or a loader's own cache() inside the capture is keyed
156
+ // without the URL.
157
+ if (!isInsideIdentityExempt()) {
158
+ refuseInCacheScope("ctx.rewrittenFrom", REWRITTEN_FROM_READ, reqCtx);
159
+ recordLoaderIdentityRead("ctx.rewrittenFrom", REWRITTEN_FROM_READ.verb);
160
+ }
161
+ return rewriteTrail(reqCtx)?.[0];
126
162
  }
127
163
  guardIdentityRead(reqCtx, "ctx.rewrittenFrom", REWRITTEN_FROM_READ);
128
164
  const href = rewriteTrail(reqCtx)?.[0];
@@ -145,15 +181,25 @@ function copyFor(holder: object, href: string | undefined): URL | undefined {
145
181
  return copy;
146
182
  }
147
183
 
184
+ /**
185
+ * The ctx defineRewrittenFrom ran on: `holder`, or the one it derives from
186
+ * (a layout's ctx is an Object.create child of the route's, withReverseScope).
187
+ */
188
+ function definedOn(holder: object): object {
189
+ while (!SOURCES.has(holder)) holder = Object.getPrototypeOf(holder);
190
+ return holder;
191
+ }
192
+
148
193
  /**
149
194
  * Non-enumerable, like `ctx.theme`: serializing a ctx (React's dev debug
150
195
  * info) must not count as a read.
151
196
  */
152
197
  const SCOPED_DESCRIPTOR: PropertyDescriptor = {
153
198
  get(this: object): URL | undefined {
199
+ const owner = definedOn(this);
154
200
  const href = readRewrittenFrom(
155
- SOURCES.get(this),
156
- READERS.get(this) ?? "render",
201
+ SOURCES.get(owner),
202
+ READERS.get(owner) ?? "render",
157
203
  );
158
204
  return copyFor(this, href);
159
205
  },
@@ -16,9 +16,10 @@ import type { NavigationStoreContextValue } from "../browser/react/context.js";
16
16
  import type { HandleData } from "../browser/types.js";
17
17
  import type { ResolvedSegment } from "../types.js";
18
18
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
19
- import type {
20
- EventController,
21
- DerivedNavigationState,
19
+ import {
20
+ DEFAULT_ACTION_STATE,
21
+ type EventController,
22
+ type DerivedNavigationState,
22
23
  } from "../browser/event-controller.js";
23
24
 
24
25
  /**
@@ -111,16 +112,11 @@ function createSsrEventController(opts: {
111
112
  getState: () => state,
112
113
  getLocation: () => location,
113
114
  subscribe: () => () => {},
114
- getActionState: () => ({
115
- state: "idle",
116
- actionId: null,
117
- payload: null,
118
- error: null,
119
- result: null,
120
- }),
115
+ getActionState: () => DEFAULT_ACTION_STATE,
121
116
  subscribeToAction: () => () => {},
122
117
  subscribeToHandles: () => () => {},
123
118
  flushRouteState: () => {},
119
+ releaseRouteState: () => {},
124
120
  setHandleData: () => {},
125
121
  getHandleState: () => handleState,
126
122
  lockHydration: () => {},
@@ -129,7 +129,7 @@ import type { CacheProfile } from "../cache/profile-registry.js";
129
129
  // onto the whole testing barrel's eager graph and break every consumer suite
130
130
  // that imports `@rangojs/router/testing` without mocking plugin-rsc.
131
131
  import type { EntryData } from "../server/context.js";
132
- import { setRouterManifest } from "../route-map-builder.js";
132
+ import { ensureRouterTrie } from "../rsc/manifest-init.js";
133
133
  import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
134
134
  import { RouterError } from "../errors.js";
135
135
  import { createProblemDetails } from "../rsc/response-error.js";
@@ -170,6 +170,8 @@ interface DispatchableRouter<TEnv> {
170
170
  /** createRouter({ version }), the consumer-set version. */
171
171
  version?: string;
172
172
  routeMap: Record<string, unknown>;
173
+ /** The routes() patterns the dev trie is rebuilt from (rsc/manifest-init.ts). */
174
+ urlpatterns?: unknown;
173
175
  middleware: MiddlewareEntry<TEnv>[];
174
176
  onError?: OnErrorCallback<TEnv>;
175
177
  /**
@@ -409,11 +411,8 @@ export async function dispatch<TEnv = any>(
409
411
  const url = new URL(req.url);
410
412
  const env = (opts.env ?? {}) as TEnv;
411
413
 
412
- // Seed the per-router manifest so reverse() resolves during handler execution.
413
414
  const routerId = router.id ?? router.routerId;
414
- if (routerId) {
415
- setRouterManifest(routerId, router.routeMap as Record<string, string>);
416
- }
415
+ await ensureRouterTrie(router);
417
416
 
418
417
  // The visitor's URL is matched before any middleware runs, so an RSC route
419
418
  // fails here, before a middleware side effect. A rewrite target is matched
@@ -51,6 +51,7 @@ export {
51
51
  PPR_REPLAY_STATUS_HEADER,
52
52
  shellCacheKey,
53
53
  SHELL_STATUS_HEADER,
54
+ visitorShellCacheKey,
54
55
  type PprReplayBypassReason,
55
56
  type PprReplayStatus,
56
57
  type ShellCachePartition,
@@ -67,5 +67,6 @@ export type {
67
67
  ServeShellRequestAction,
68
68
  ServeShellRequestOptions,
69
69
  ServeShellRequestResult,
70
+ ShellRequestAction,
70
71
  ShellRequestHandles,
71
72
  } from "./serve-shell-request.js";
@@ -112,6 +112,7 @@ export {
112
112
  PPR_REPLAY_STATUS_HEADER,
113
113
  shellCacheKey,
114
114
  SHELL_STATUS_HEADER,
115
+ visitorShellCacheKey,
115
116
  } from "./shell-status.js";
116
117
  export type {
117
118
  PprReplayBypassReason,
@@ -19,9 +19,10 @@ import type { ShouldRevalidateFn } from "../../types.js";
19
19
  *
20
20
  * A Vitest project runs no hoist plugin, so the source's revalidate()
21
21
  * predicates are plain functions. Each is registered under a key named for
22
- * its reference, route and loader, as the plugin's generated module registers
23
- * its own, and the projection carries the keys: materialization attaches them
24
- * and the server runs them, as in an app.
22
+ * its reference, route or intercept, and loader, as the plugin's generated
23
+ * module registers its own, and the projection carries the keys:
24
+ * materialization attaches them and the server runs them, as in an app. A
25
+ * boolean rides in the projection as itself, as the plugin leaves a literal.
25
26
  */
26
27
  export function installClientUrlsProjection(
27
28
  referenceId: string,
@@ -50,21 +51,40 @@ function withRevalidateKeys(
50
51
  projection: ClientUrlProjection,
51
52
  ): ClientUrlProjection {
52
53
  const fns: Record<string, ShouldRevalidateFn<any, any>> = {};
53
- const routes = projection.routes.map((route) => {
54
- const record = definition.routes.find(({ id }) => id === route.id);
55
- const loaderRevalidate = (record?.loaders ?? []).map(
54
+ // A route's or an intercept's loaders, keyed under `owner`.
55
+ const withKeys = <T extends object>(
56
+ projected: T,
57
+ owner: string,
58
+ loaders: ClientUrlPatterns["routes"][number]["loaders"] | undefined,
59
+ ): T => {
60
+ const loaderRevalidate = (loaders ?? []).map(
56
61
  ({ revalidate }, loaderIndex) =>
57
62
  revalidate.map((entry, index) => {
58
63
  if (isHoistedRevalidateRef(entry)) return entry.$$rangoRevalidate;
59
- const key = `${referenceId}:${route.id}:${loaderIndex}:${index}`;
64
+ if (typeof entry === "boolean") return entry;
65
+ const key = `${referenceId}:${owner}:${loaderIndex}:${index}`;
60
66
  fns[key] = entry;
61
67
  return key;
62
68
  }),
63
69
  );
64
70
  return loaderRevalidate.some((keys) => keys.length > 0)
65
- ? { ...route, loaderRevalidate }
66
- : route;
67
- });
71
+ ? { ...projected, loaderRevalidate }
72
+ : projected;
73
+ };
74
+ const routes = projection.routes.map((route) =>
75
+ withKeys(
76
+ route,
77
+ route.id,
78
+ definition.routes.find(({ id }) => id === route.id)?.loaders,
79
+ ),
80
+ );
81
+ const intercepts = projection.intercepts?.map((intercept, index) =>
82
+ withKeys(
83
+ intercept,
84
+ `intercept${index}`,
85
+ definition.intercepts[index]?.loaders,
86
+ ),
87
+ );
68
88
  registerClientUrlRevalidators(fns);
69
- return { ...projection, routes };
89
+ return { ...projection, routes, ...(intercepts && { intercepts }) };
70
90
  }
@@ -23,13 +23,16 @@
23
23
  * SEEDED directly into client context (see the `loaders` / `locationState` /
24
24
  * `handles` / `outletPending` options) — nothing is executed on the server.
25
25
  * This exercises the context read path, not the run path.
26
- * - navigate() does NOT drive the navigation lifecycle: useNavigation().state,
27
- * useLinkStatus().pending, and useAction().state stay "idle". Assert
28
- * pending/loading/submitting transition states with renderServerTree / e2e
29
- * instead (navigate() warns once if used). The one pending state it does
30
- * model is the held-navigation useLoader().isLoading: a `transition` spec
31
- * plus a pending Promise seeded via navigate(url, { loaders }) commits
32
- * through production's commitInTransition (browser/partial-update.ts).
26
+ * - navigate() drives the navigation lifecycle as a click does:
27
+ * useNavigation() reads loading from the call and idle with the commit,
28
+ * and useLinkStatus().pending is true for a link to the destination until
29
+ * the commit is handed to React. A `transition` spec plus a pending
30
+ * Promise seeded via navigate(url, { loaders }) commits through
31
+ * production's commitInTransition (browser/partial-update.ts), which React
32
+ * holds: useNavigation() stays loading and the held reader's
33
+ * useLoader().isLoading reads true until the data lands. Without
34
+ * `transition` the commit is urgent (production's same-structure hold is
35
+ * not modeled). useAction().state stays idle: no action runs.
33
36
  * - A spec's transition({ when }) decides every commit with production's
34
37
  * browser decision: navigate() and useRouter().push model kind "push",
35
38
  * useRouter().replace kind "replace", refresh() kind "revalidate".
@@ -81,7 +84,10 @@ import type {
81
84
  NavigationBridge,
82
85
  UpdateSubscriber,
83
86
  } from "../browser/types.js";
84
- import type { EventController } from "../browser/event-controller.js";
87
+ import type {
88
+ EventController,
89
+ NavigationHandle,
90
+ } from "../browser/event-controller.js";
85
91
  import type { ResolvedSegment, RscMetadata } from "../browser/types.js";
86
92
  import { NavigationProvider } from "../browser/react/NavigationProvider.js";
87
93
  import {
@@ -407,9 +413,10 @@ export interface TestRouterHandle {
407
413
  * pending Promise suspends the read like a streaming Flight chunk. When a
408
414
  * spec in the chain has `transition`, the commit goes through production's
409
415
  * commitInTransition: the reader stays on screen with
410
- * `useLoader().isLoading === true` until the promise settles. Settle it
411
- * inside RTL's `act()` to flush the commit. Without `transition` the commit
412
- * is urgent and the read suspends to the nearest Suspense boundary.
416
+ * `useLoader().isLoading === true`, and `useNavigation().state` reads
417
+ * "loading", until the promise settles. Settle it inside RTL's `act()` to
418
+ * flush the commit. Without `transition` the commit is urgent and the read
419
+ * suspends to the nearest Suspense boundary.
413
420
  *
414
421
  * @example
415
422
  * await router.navigate("/products/2", { loaders: [[ProductLoader, next]] });
@@ -429,10 +436,16 @@ export interface TestRouterHandle {
429
436
  * location state as a production replace does). With neither, history is
430
437
  * left alone and readers keep the state they have: `navigate(url)` and
431
438
  * `navigate(url, { replace: false })` are the same call.
439
+ *
440
+ * `options.handles` are the destination's handle pushes (`[handle,
441
+ * values]`, attached to its leaf route as in `renderRoute`'s own
442
+ * `handles`). They arrive on the response's handle stream, so `useHandle`
443
+ * readers take them with the page's commit: while React holds the page,
444
+ * readers keep the values of the page being left.
432
445
  */
433
446
  navigate(
434
447
  url: string,
435
- options?: Pick<RenderRouteOptions, "loaders"> &
448
+ options?: Pick<RenderRouteOptions, "loaders" | "handles"> &
436
449
  Pick<NavigateOptions, "state" | "replace" | "transition">,
437
450
  ): Promise<void>;
438
451
  /**
@@ -791,17 +804,36 @@ export async function renderRoute(
791
804
  };
792
805
  }
793
806
 
794
- let warnedNavLifecycle = false;
807
+ type NavigateCall = Pick<RenderRouteOptions, "loaders" | "handles"> & {
808
+ history?: Pick<NavigateOptionsInternal, "state" | "replace" | "transition">;
809
+ kind?: TransitionWhenKind;
810
+ transition?: boolean;
811
+ };
812
+ // The navigation's lifecycle, as navigation-transaction.ts drives it:
813
+ // useNavigation() reads loading from the call, delivered before the commit
814
+ // as a click's notification goes out while its response is in flight, and
815
+ // idle with the location at the commit, which React may hold.
795
816
  const navigate = async (
796
817
  target: string,
797
- navOptions?: Pick<RenderRouteOptions, "loaders"> & {
798
- history?: Pick<
799
- NavigateOptionsInternal,
800
- "state" | "replace" | "transition"
801
- >;
802
- kind?: TransitionWhenKind;
803
- transition?: boolean;
804
- },
818
+ navOptions?: NavigateCall,
819
+ ): Promise<void> => {
820
+ const handle = eventController.startNavigation(
821
+ new URL(target, TEST_ORIGIN).href,
822
+ );
823
+ // Synchronous, in the caller's task: a <Link> click's own act() scope
824
+ // takes it, and no act() of the caller's can overlap it.
825
+ act(() => eventController.flushRouteState());
826
+ try {
827
+ await commitNavigation(handle, target, navOptions);
828
+ } finally {
829
+ // Back to idle when the commit never came (a render that threw).
830
+ handle[Symbol.dispose]();
831
+ }
832
+ };
833
+ const commitNavigation = async (
834
+ handle: NavigationHandle,
835
+ target: string,
836
+ navOptions?: NavigateCall,
805
837
  ): Promise<void> => {
806
838
  // A useRouter().push/replace or <Link> navigation (`history`) writes its
807
839
  // entry the way production does (navigation-bridge.ts navigate ->
@@ -819,21 +851,6 @@ export async function renderRoute(
819
851
  : undefined,
820
852
  )
821
853
  : undefined;
822
- // No server fetch, so the navigation lifecycle never starts: the state
823
- // useNavigation()/useLinkStatus()/useAction() read stays "idle" — asserting
824
- // a pending/loading/submitting state here proves nothing. Warn once (per
825
- // render) under the test runner so that false-confidence trap is loud.
826
- if (isUnderTestRunner() && !warnedNavLifecycle) {
827
- warnedNavLifecycle = true;
828
- console.warn(
829
- "renderRoute: navigate()/useRouter().push do NOT drive the navigation " +
830
- "lifecycle. useNavigation().state, useLinkStatus().pending, and " +
831
- 'useAction().state stay "idle" here. Assert params/pathname/content ' +
832
- "after navigate(); use renderServerTree or e2e to assert " +
833
- "pending/loading/submitting transition states. A held " +
834
- "useLoader().isLoading is modeled: see navigate(url, { loaders }).",
835
- );
836
- }
837
854
  const nextUrl = new URL(target, TEST_ORIGIN);
838
855
  const match = resolve(nextUrl.pathname);
839
856
  const segments = buildSegments(
@@ -843,6 +860,23 @@ export async function renderRoute(
843
860
  mount,
844
861
  );
845
862
  const metadata = makeMetadata(nextUrl.pathname, segments, match.params);
863
+ // The destination's handle pushes arrive on the response's handle
864
+ // stream, as production's do, behind the page's commit.
865
+ const navHandles = navOptions?.handles;
866
+ if (navHandles) {
867
+ const leafId =
868
+ [...segments].reverse().find((s) => s.type === "route")?.id ??
869
+ segments[segments.length - 1]?.id;
870
+ const pushed: HandleDataSeed = {};
871
+ for (const [h, values] of navHandles) {
872
+ if (leafId === undefined) continue;
873
+ const id = (h as unknown as { $$id: string }).$$id;
874
+ (pushed[id] ??= {})[leafId] = values;
875
+ }
876
+ metadata.handles = (async function* () {
877
+ yield pushed;
878
+ })();
879
+ }
846
880
  // Production's browser-run transition({ when }) decision
847
881
  // (browser/partial-update.ts): the committed location is the source.
848
882
  const kind: TransitionWhenKind =
@@ -872,7 +906,10 @@ export async function renderRoute(
872
906
  outletPending: options.outletPending,
873
907
  transitionGatedOff: gatedOff,
874
908
  });
875
- eventController.setLocation(nextUrl);
909
+ // A newer navigate() started meanwhile: this one commits nothing, as
910
+ // production's (browser/partial-update.ts) returns on its aborted signal.
911
+ if (handle.signal.aborted) return;
912
+ handle.complete(nextUrl);
876
913
  eventController.setParams(match.params);
877
914
  store.setCurrentUrl(nextUrl.href);
878
915
  store.setSegmentIds(segments.map((s) => s.id));
@@ -892,9 +929,14 @@ export async function renderRoute(
892
929
  // same-structure / fully-prefetched / optimistic hold lanes are not
893
930
  // modeled: without transition() the commit stays urgent.
894
931
  if (shouldStartViewTransition(segments, gatedOff)) {
895
- commitInTransition(store.loaders, emit, segments, { root, metadata }, [
896
- "navigation",
897
- ]);
932
+ commitInTransition(
933
+ store.loaders,
934
+ emit,
935
+ segments,
936
+ { root, metadata },
937
+ kind,
938
+ ["navigation"],
939
+ );
898
940
  } else {
899
941
  emit({ root, metadata });
900
942
  }
@@ -1024,6 +1066,7 @@ export async function renderRoute(
1024
1066
  navigate: (target, navOptions) =>
1025
1067
  navigate(target, {
1026
1068
  loaders: navOptions?.loaders,
1069
+ handles: navOptions?.handles,
1027
1070
  transition: navOptions?.transition,
1028
1071
  history:
1029
1072
  navOptions?.state !== undefined || navOptions?.replace