@rangojs/router 0.31.0 → 0.33.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 (192) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10143
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/history-state.d.ts +13 -2
  19. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  20. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  21. package/dist/types/browser/navigation-client.d.ts +19 -0
  22. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  23. package/dist/types/browser/navigation-store.d.ts +2 -2
  24. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  25. package/dist/types/browser/partial-update.d.ts +13 -0
  26. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  27. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  28. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  29. package/dist/types/browser/rango-state.d.ts +34 -5
  30. package/dist/types/browser/react/Link.d.ts +19 -13
  31. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  32. package/dist/types/browser/types.d.ts +25 -0
  33. package/dist/types/build/generate-route-types.d.ts +1 -1
  34. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  35. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  36. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  37. package/dist/types/cache/cache-scope.d.ts +4 -2
  38. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  39. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  40. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  41. package/dist/types/cache/segment-codec.d.ts +2 -2
  42. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  43. package/dist/types/cache/types.d.ts +12 -7
  44. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  45. package/dist/types/client-urls/types.d.ts +7 -5
  46. package/dist/types/prefetch-markers.d.ts +9 -0
  47. package/dist/types/prefetch-source-scope.d.ts +66 -0
  48. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  49. package/dist/types/route-definition/redirect.d.ts +5 -0
  50. package/dist/types/router/intercept-resolution.d.ts +25 -11
  51. package/dist/types/router/match-context.d.ts +1 -0
  52. package/dist/types/router/match-handlers.d.ts +1 -1
  53. package/dist/types/router/middleware-types.d.ts +6 -0
  54. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  55. package/dist/types/router/router-context.d.ts +1 -1
  56. package/dist/types/router/router-options.d.ts +6 -3
  57. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  58. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  59. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  60. package/dist/types/router/source-reads.d.ts +53 -0
  61. package/dist/types/router/types.d.ts +1 -1
  62. package/dist/types/rsc/helpers.d.ts +8 -3
  63. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  64. package/dist/types/rsc/request-stages.d.ts +4 -0
  65. package/dist/types/rsc/shell-serve.d.ts +6 -1
  66. package/dist/types/server/context.d.ts +23 -6
  67. package/dist/types/server/cookie-store.d.ts +15 -1
  68. package/dist/types/server/document-location-state.d.ts +59 -0
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +65 -12
  71. package/dist/types/testing/e2e/server.d.ts +16 -1
  72. package/dist/types/testing/render-route.d.ts +83 -2
  73. package/dist/types/testing/serve-shell-request.d.ts +15 -3
  74. package/dist/types/types/handler-context.d.ts +21 -4
  75. package/dist/types/types/loader-types.d.ts +7 -4
  76. package/dist/types/types/segments.d.ts +18 -1
  77. package/dist/types/urls/path-helper-types.d.ts +7 -5
  78. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  79. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  80. package/dist/vite/index.js +451 -191
  81. package/package.json +2 -2
  82. package/skills/app-structure/SKILL.md +74 -0
  83. package/skills/app-structure/e-commerce.md +10 -9
  84. package/skills/app-structure/large-app.md +15 -2
  85. package/skills/caching/SKILL.md +10 -5
  86. package/skills/client-urls/SKILL.md +18 -11
  87. package/skills/comparison/references/framework-comparison.md +16 -10
  88. package/skills/document-cache/SKILL.md +13 -0
  89. package/skills/hooks/navigation.md +8 -8
  90. package/skills/hooks/state.md +132 -12
  91. package/skills/intercept/SKILL.md +33 -11
  92. package/skills/layout/SKILL.md +11 -0
  93. package/skills/links/SKILL.md +101 -4
  94. package/skills/loader/SKILL.md +49 -22
  95. package/skills/migrate-nextjs/SKILL.md +8 -2
  96. package/skills/migrate-react-router/data-and-actions.md +5 -0
  97. package/skills/ppr/SKILL.md +38 -5
  98. package/skills/prerender/SKILL.md +4 -1
  99. package/skills/route/SKILL.md +6 -6
  100. package/skills/router-setup/SKILL.md +2 -1
  101. package/skills/server-actions/SKILL.md +49 -8
  102. package/skills/testing/client-components.md +73 -32
  103. package/skills/testing/serve-shell-request.md +45 -20
  104. package/skills/testing/server-actions.md +8 -0
  105. package/skills/ux-architecture/SKILL.md +67 -15
  106. package/src/bin/rango.ts +39 -13
  107. package/src/browser/action-fence.ts +72 -0
  108. package/src/browser/cookie-name.ts +82 -14
  109. package/src/browser/history-state.ts +24 -2
  110. package/src/browser/invalidate-client-cache.ts +9 -7
  111. package/src/browser/navigation-bridge.ts +164 -32
  112. package/src/browser/navigation-client.ts +95 -20
  113. package/src/browser/navigation-store-handle.ts +67 -5
  114. package/src/browser/navigation-store.ts +86 -17
  115. package/src/browser/navigation-transaction.ts +0 -6
  116. package/src/browser/partial-update.ts +31 -12
  117. package/src/browser/prefetch/cache.ts +188 -11
  118. package/src/browser/prefetch/fetch.ts +211 -51
  119. package/src/browser/prefetch/queue.ts +3 -2
  120. package/src/browser/rango-state.ts +50 -8
  121. package/src/browser/react/Link.tsx +24 -16
  122. package/src/browser/react/location-state-shared.ts +8 -1
  123. package/src/browser/react/use-navigation.ts +1 -1
  124. package/src/browser/rsc-router.tsx +13 -3
  125. package/src/browser/server-action-bridge.ts +3 -3
  126. package/src/browser/types.ts +25 -0
  127. package/src/build/generate-route-types.ts +1 -0
  128. package/src/build/route-types/include-resolution.ts +40 -3
  129. package/src/build/route-types/router-processing.ts +170 -25
  130. package/src/build/route-types/scan-filter.ts +1 -0
  131. package/src/build/runtime-discovery.ts +46 -8
  132. package/src/cache/cache-key-utils.ts +12 -8
  133. package/src/cache/cache-runtime.ts +5 -1
  134. package/src/cache/cache-scope.ts +28 -7
  135. package/src/cache/cf/cf-cache-types.ts +9 -5
  136. package/src/cache/document-cache-policy.ts +37 -1
  137. package/src/cache/document-cache.ts +12 -0
  138. package/src/cache/memory-segment-store.ts +3 -2
  139. package/src/cache/segment-codec.ts +2 -2
  140. package/src/cache/shell-snapshot.ts +1 -1
  141. package/src/cache/types.ts +12 -7
  142. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  143. package/src/client-urls/types.ts +7 -5
  144. package/src/prefetch-markers.ts +10 -0
  145. package/src/prefetch-source-scope.ts +137 -0
  146. package/src/route-definition/helpers-types.ts +8 -5
  147. package/src/route-definition/redirect.ts +5 -0
  148. package/src/router/intercept-resolution.ts +94 -50
  149. package/src/router/loader-resolution.ts +8 -6
  150. package/src/router/match-api.ts +33 -29
  151. package/src/router/match-context.ts +5 -0
  152. package/src/router/match-handlers.ts +2 -1
  153. package/src/router/match-middleware/cache-lookup.ts +39 -14
  154. package/src/router/match-result.ts +45 -85
  155. package/src/router/middleware-types.ts +6 -0
  156. package/src/router/navigation-snapshot.ts +18 -3
  157. package/src/router/prefetch-cache-ttl.ts +15 -3
  158. package/src/router/revalidation.ts +63 -13
  159. package/src/router/router-context.ts +2 -1
  160. package/src/router/router-options.ts +6 -3
  161. package/src/router/segment-resolution/loader-cache.ts +21 -3
  162. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  163. package/src/router/segment-resolution/revalidation.ts +40 -12
  164. package/src/router/source-reads.ts +122 -0
  165. package/src/router/types.ts +2 -1
  166. package/src/router.ts +11 -12
  167. package/src/rsc/helpers.ts +20 -6
  168. package/src/rsc/prefetch-render-error.ts +7 -2
  169. package/src/rsc/progressive-enhancement.ts +8 -2
  170. package/src/rsc/request-stages.ts +87 -1
  171. package/src/rsc/rsc-rendering.ts +78 -32
  172. package/src/rsc/server-action.ts +2 -1
  173. package/src/rsc/shell-capture.ts +4 -5
  174. package/src/rsc/shell-serve.ts +12 -1
  175. package/src/segment-system.tsx +1 -1
  176. package/src/server/context.ts +37 -6
  177. package/src/server/cookie-store.ts +22 -1
  178. package/src/server/document-location-state.ts +334 -0
  179. package/src/server/handle-store.ts +2 -0
  180. package/src/server/request-context.ts +137 -33
  181. package/src/testing/dispatch.ts +6 -0
  182. package/src/testing/e2e/fixture.ts +4 -12
  183. package/src/testing/e2e/server.ts +91 -4
  184. package/src/testing/render-route.tsx +309 -10
  185. package/src/testing/run-revalidate.ts +11 -1
  186. package/src/testing/serve-shell-request.ts +22 -3
  187. package/src/types/handler-context.ts +21 -4
  188. package/src/types/loader-types.ts +7 -4
  189. package/src/types/segments.ts +18 -1
  190. package/src/urls/path-helper-types.ts +7 -5
  191. package/src/vite/temp-rsc-server.ts +2 -2
  192. package/src/vite/utils/forward-user-plugins.ts +13 -5
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Recording what revalidate() rules read of the source page, so a prefetch
3
+ * answer a rule kept is shared between the pages with equal values of what
4
+ * was read (prefetch-source-scope.ts).
5
+ */
6
+
7
+ import {
8
+ createSourceReads,
9
+ formatSourceLabel,
10
+ mergeSourceReads,
11
+ PARAMS_SCOPE_PREFIX,
12
+ shapeOfReads,
13
+ type SourcePage,
14
+ type SourceReads,
15
+ } from "../prefetch-source-scope.js";
16
+
17
+ /**
18
+ * The source group of a match whose diff a source read decided: the reads
19
+ * every kept decision made, unioned, and the source page they read.
20
+ */
21
+ export interface SourceHeld extends SourcePage {
22
+ reads: SourceReads;
23
+ }
24
+
25
+ /**
26
+ * A copy of `params` whose reads land in `reads`: an enumerable getter per
27
+ * key, so a spread, Object.entries, JSON.stringify or structuredClone reads
28
+ * every key. Not a Proxy: structuredClone refuses one. A read of an absent
29
+ * key is not recorded and needs no record: the key set is part of the group
30
+ * (`reads.keys`), so a page in the group lacks that key too.
31
+ */
32
+ export function recordingParams(
33
+ params: Readonly<Record<string, string>>,
34
+ reads: SourceReads,
35
+ ): Record<string, string> {
36
+ reads.keys = true;
37
+ const view: Record<string, string> = {};
38
+ for (const name of Object.keys(params)) {
39
+ Object.defineProperty(view, name, {
40
+ enumerable: true,
41
+ configurable: true,
42
+ get() {
43
+ reads.params.add(name);
44
+ return params[name];
45
+ },
46
+ // A rule may write to the object, as to the plain one it replaces:
47
+ // what it wrote is its own value, not the source's.
48
+ set(value: string) {
49
+ Object.defineProperty(view, name, {
50
+ value,
51
+ writable: true,
52
+ enumerable: true,
53
+ configurable: true,
54
+ });
55
+ },
56
+ });
57
+ }
58
+ return view;
59
+ }
60
+
61
+ /**
62
+ * Add `reads` to the source group of the match `context` decides for. All
63
+ * of one match's decisions read the same source page; a caller that reads
64
+ * no route name passes none.
65
+ */
66
+ export function markSourceHeld(
67
+ context: { _sourceHeld?: SourceHeld },
68
+ reads: SourceReads,
69
+ page: SourcePage,
70
+ ): void {
71
+ const held = (context._sourceHeld ??= {
72
+ reads: createSourceReads(),
73
+ params: page.params,
74
+ routeName: undefined,
75
+ });
76
+ if (page.routeName !== undefined) held.routeName = page.routeName;
77
+ mergeSourceReads(held.reads, reads);
78
+ }
79
+
80
+ /**
81
+ * The `x-rsc-prefetch-scope` of a partial response, or undefined when every
82
+ * source page may share it.
83
+ *
84
+ * - `source`: the exact source page's. A route an intercept targets renders
85
+ * the modal or the full page depending on the source, so a full page
86
+ * prefetched where the intercept does not apply would otherwise serve a
87
+ * later click where it does (#1007); a response with deferred units
88
+ * answers one page's prefetch (`sourceOnly`); a kept rule that read
89
+ * `currentUrl` can depend on anything in it; and a source-held answer that
90
+ * sends nothing (`sentNothing`) commits treeless on the page that adopts
91
+ * it, which keeps its whole tree without checking it holds the ids the
92
+ * answer kept (partial-update.ts): only the source is known to hold them.
93
+ * - `params; <label>`: a revalidate() rule read the source's params or route
94
+ * name and kept a segment. A page that adopted the answer would keep its
95
+ * own copy of that segment (#1331), which is right only where the rule
96
+ * decides the same: on pages with equal values of what it read. The label
97
+ * names them (prefetch-source-scope.ts); the browser stores the answer
98
+ * under it. A page that lacks a kept id refetches (the commit's
99
+ * missing-segment check), as for a shared answer.
100
+ */
101
+ export function prefetchScopeOf(
102
+ sourceOnly: boolean,
103
+ held: SourceHeld | undefined,
104
+ sentNothing: boolean,
105
+ ): string | undefined {
106
+ if (sourceOnly) return "source";
107
+ if (!held) return undefined;
108
+ if (held.reads.url || sentNothing) return "source";
109
+ return (
110
+ PARAMS_SCOPE_PREFIX + formatSourceLabel(shapeOfReads(held.reads), held)
111
+ );
112
+ }
113
+
114
+ /** Every param of the source read: its params must equal the page's. */
115
+ export function readsOfAllParams(
116
+ params: Readonly<Record<string, string>>,
117
+ ): SourceReads {
118
+ const reads = createSourceReads();
119
+ reads.keys = true;
120
+ for (const name of Object.keys(params)) reads.params.add(name);
121
+ return reads;
122
+ }
@@ -91,10 +91,11 @@ export interface MatchApiDeps<TEnv = any> {
91
91
  findMatch: (pathname: string, ms?: any) => any;
92
92
  getMetricsStore: () => any;
93
93
  findInterceptForRoute: (
94
- routeKey: string,
94
+ routeKeys: string | readonly string[],
95
95
  parentEntry: EntryData | null,
96
96
  selectorContext: InterceptSelectorContext | null,
97
97
  isAction: boolean,
98
+ held?: ReadonlySet<string>,
98
99
  ) => { intercept: InterceptEntry; entry: EntryData } | null;
99
100
  callOnError: SegmentResolutionDeps<TEnv>["callOnError"];
100
101
  findNearestErrorBoundary: SegmentResolutionDeps<TEnv>["findNearestErrorBoundary"];
package/src/router.ts CHANGED
@@ -654,12 +654,21 @@ export function createRouter<TEnv = any>(
654
654
  viewTransitionDefault: viewTransitionOption,
655
655
  };
656
656
 
657
+ // Create segment resolution wrappers bound to segmentDeps
658
+ const {
659
+ resolveLoadersOnly,
660
+ buildEntryRevalidateMap,
661
+ resolveAllSegments,
662
+ findInterceptForRoute,
663
+ resolveInterceptEntry,
664
+ resolveInterceptLoadersOnly,
665
+ } = createSegmentWrappers<TEnv>(segmentDeps);
666
+
657
667
  // Match API dependencies
658
668
  const matchApiDeps: MatchApiDeps<TEnv> = {
659
669
  findMatch: (pathname: string, ms?: any) => findMatch(pathname, ms),
660
670
  getMetricsStore,
661
- findInterceptForRoute: (routeKey, parentEntry, selectorContext, isAction) =>
662
- findInterceptForRoute(routeKey, parentEntry, selectorContext, isAction),
671
+ findInterceptForRoute,
663
672
  callOnError,
664
673
  findNearestErrorBoundary,
665
674
  // Use per-router manifest when available, otherwise the static named map
@@ -667,16 +676,6 @@ export function createRouter<TEnv = any>(
667
676
  getRouteMap: () => getRouterManifest(routerId) ?? mergedRouteMap,
668
677
  };
669
678
 
670
- // Create segment resolution wrappers bound to segmentDeps
671
- const {
672
- resolveLoadersOnly,
673
- buildEntryRevalidateMap,
674
- resolveAllSegments,
675
- findInterceptForRoute,
676
- resolveInterceptEntry,
677
- resolveInterceptLoadersOnly,
678
- } = createSegmentWrappers<TEnv>(segmentDeps);
679
-
680
679
  // Lazy evaluation deps — captures closure state for extracted evaluateLazyEntry
681
680
  const lazyEvalDeps: LazyEvalDeps<TEnv> = {
682
681
  routesEntries,
@@ -10,7 +10,10 @@ import {
10
10
  setRequestContextParams,
11
11
  } from "../server/request-context.js";
12
12
  import type { RequestContext } from "../server/request-context.js";
13
- import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
13
+ import {
14
+ resolveLocationStateEntries,
15
+ type LocationStateEntry,
16
+ } from "../browser/react/location-state-shared.js";
14
17
  import {
15
18
  isRedirectResponse,
16
19
  isWebSocketUpgradeResponse,
@@ -340,14 +343,25 @@ export function interceptRedirectForPartial(
340
343
  /**
341
344
  * Attach location state set during a request to a payload's metadata.
342
345
  * No-op if no location state was set. Callers must ensure payload.metadata
343
- * is populated (the non-null assertion holds for the partial/action payloads
344
- * that reach this helper).
346
+ * is populated. A `document` payload (a page load's or a no-JS form post's
347
+ * bootstrap payload) that carries state marks the request
348
+ * `_documentLocationState`: it is the visitor's, so no shared cache keeps it
349
+ * (server/document-location-state.ts). `locationState` defaults to the
350
+ * current request context's; a shell HIT's tail passes the state set before
351
+ * its render (rsc/rsc-rendering.ts serveShellHit).
345
352
  */
346
- export function attachLocationStateIfPresent(payload: RscPayload): void {
347
- const locationState = getLocationState();
348
- if (locationState) {
353
+ export function attachLocationStateIfPresent(
354
+ payload: RscPayload,
355
+ document = false,
356
+ locationState: LocationStateEntry[] | undefined = getLocationState(),
357
+ ): void {
358
+ if (locationState && locationState.length > 0) {
349
359
  payload.metadata!.locationState =
350
360
  resolveLocationStateEntries(locationState);
361
+ if (document) {
362
+ const ctx = _getRequestContext();
363
+ if (ctx) ctx._documentLocationState = true;
364
+ }
351
365
  }
352
366
  }
353
367
 
@@ -11,8 +11,13 @@
11
11
  * So the body ends cleanly with one more row, PREFETCH_FAILED_ROW, which the
12
12
  * Flight decoder skips and teeWithCompletion (browser/response-adapter.ts)
13
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.
14
+ * and never respawns it. The router puts no Cache-Control of its own on a
15
+ * prefetch with a live part, the only kind whose render can report an error
16
+ * here, so the browser HTTP cache keeps none of it unless the route sets one;
17
+ * a prefetch with nothing live in it carries `private, max-age`
18
+ * (request-stages.ts applyStoredPrefetchCacheControl); a prefetch
19
+ * scoped to its source page or carrying deferred units is sent
20
+ * `private, no-cache` (rsc-rendering.ts NOT_REUSED).
16
21
  *
17
22
  * The errors are the render's `_renderErrors`: a Flight error row, a streamed
18
23
  * handler recovered into its boundary, a loader that threw (redirect() and
@@ -13,6 +13,7 @@ import {
13
13
  setRequestContextParams,
14
14
  } from "../server/request-context.js";
15
15
  import { printServerError } from "../server/print-server-error.js";
16
+ import { runActionBody } from "../server/cookie-store.js";
16
17
  import { createSsrHtmlStage } from "./ssr-setup.js";
17
18
  import type { MiddlewareFn } from "../router/middleware.js";
18
19
  import { executeMiddleware } from "../router/middleware.js";
@@ -25,6 +26,7 @@ import type { RscPayload, ReactFormState } from "./types.js";
25
26
  import type { MatchResult } from "../types.js";
26
27
  import {
27
28
  actionRenderRequest,
29
+ attachLocationStateIfPresent,
28
30
  createResponseWithMergedHeaders,
29
31
  finalizeResponse,
30
32
  buildRouteMiddlewareEntries,
@@ -186,7 +188,7 @@ async function runFormAction<TEnv>(
186
188
  // Meter the no-JS form action as the action phase, same as the JS path.
187
189
  actionResult = await observePhase(
188
190
  PHASES.action(useActionStateId ?? "useActionState"),
189
- () => boundAction(),
191
+ () => runActionBody(() => boundAction()),
190
192
  );
191
193
  } catch (error) {
192
194
  // Handle thrown redirect (e.g., throw redirect('/path'))
@@ -215,7 +217,7 @@ async function runFormAction<TEnv>(
215
217
  try {
216
218
  const loadedAction = await ctx.loadServerAction(directActionId);
217
219
  actionResult = await observePhase(PHASES.action(directActionId), () =>
218
- loadedAction.apply(null, args),
220
+ runActionBody(() => loadedAction.apply(null, args)),
219
221
  );
220
222
  } catch (error) {
221
223
  // Handle thrown redirect (e.g., throw redirect('/path'))
@@ -448,6 +450,10 @@ function renderPeResponse<TEnv>(
448
450
  // slot instead of failing the payload root and the whole document.
449
451
  ...(reactFormState ? { formState: Promise.resolve(reactFormState) } : {}),
450
452
  };
453
+ // The action's location state rides the re-rendered document's bootstrap
454
+ // payload, as a JS action's rides its response. Not on a failure render,
455
+ // like the JS path.
456
+ if (!failure) attachLocationStateIfPresent(payload, true);
451
457
 
452
458
  const trace = getRequestContext()._activeRoutine;
453
459
 
@@ -84,6 +84,14 @@ import {
84
84
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
85
85
  import type { CacheErrorCategory } from "../cache/cache-error.js";
86
86
  import { WIRE } from "../wire-params.js";
87
+ import { isPrefetchKind } from "../router/segment-resolution/prefetch-deferral.js";
88
+ import { storedPrefetchCacheControl } from "../router/prefetch-cache-ttl.js";
89
+ import { carriesClientState } from "../cache/document-cache-policy.js";
90
+ import {
91
+ carryLocationStateAcrossRedirect,
92
+ consumeDocumentLocationState,
93
+ markDocumentResponsePrivate,
94
+ } from "../server/document-location-state.js";
87
95
 
88
96
  /**
89
97
  * The router members the stages read. The handler's `RangoInternal` carries
@@ -96,6 +104,10 @@ export interface StageRouter<TEnv = unknown> {
96
104
  readonly telemetry?: TelemetrySink;
97
105
  readonly timeouts?: ResolvedTimeouts;
98
106
  readonly onTimeout?: OnTimeoutCallback<TEnv>;
107
+ /** Names the cookie location state crosses a document redirect in. */
108
+ readonly resolvedStateCookieName: string;
109
+ /** The in-memory prefetch TTL in ms; also a stored prefetch's max-age. */
110
+ readonly prefetchCacheTTL: number;
99
111
  }
100
112
 
101
113
  /** Report an error through the router's onError. */
@@ -409,6 +421,14 @@ export async function runRequestPasses<TEnv>(
409
421
  opts: RequestPassesOptions<TEnv>,
410
422
  ): Promise<RequestPassesResult> {
411
423
  const { ctx, env, variables } = opts;
424
+ // Before any middleware: a document cache lookup, a PPR shell serve and the
425
+ // render all see the state a document redirect carried here.
426
+ consumeDocumentLocationState(
427
+ opts.reqCtx,
428
+ opts.request,
429
+ opts.url,
430
+ ctx.router.resolvedStateCookieName,
431
+ );
412
432
  let passIndex = 0;
413
433
  let hasMiddleware = false;
414
434
  const runChain = (
@@ -667,6 +687,57 @@ export interface FinalizeRequestOptions<TEnv> {
667
687
  beforeGuard?: (response: Response) => void;
668
688
  }
669
689
 
690
+ /**
691
+ * A prefetch with nothing live in it (`_servedFromStorage`: a document cache
692
+ * HIT, or a partial whose every sent segment came from storage and ran no
693
+ * loader) leaves with the router's prefetch Cache-Control, so the browser
694
+ * HTTP cache can answer a later prefetch or the click with it.
695
+ * Nothing in such an answer can fail after its 200 left; any other prefetch
696
+ * carries none (#1170). Set here, after every middleware: the document cache
697
+ * reads the route's own header when it stores the response (s-maxage), the
698
+ * browser gets this one, in place of any other the route set.
699
+ *
700
+ * Left as it is: a partial sent NOT_REUSED (scoped to its source page, or
701
+ * carrying deferred units), a header that forbids storing it (`no-store`,
702
+ * `no-cache`, a zero `max-age`), a redirect a middleware returned after the render (`redirect`,
703
+ * converted to a Flight redirect above), and a response carrying one
704
+ * client's state (a per-client signal header, location state), which a
705
+ * stored copy would replay to later requests.
706
+ */
707
+ function applyStoredPrefetchCacheControl(
708
+ response: Response,
709
+ reqCtx: RequestContext<any>,
710
+ url: URL,
711
+ router: StageRouter<any>,
712
+ redirect: boolean,
713
+ ): void {
714
+ if (
715
+ redirect ||
716
+ !reqCtx._servedFromStorage ||
717
+ !isPrefetchKind(reqCtx._requestKind) ||
718
+ !url.searchParams.has(WIRE.partial) ||
719
+ reqCtx._payloadSourceScoped ||
720
+ reqCtx._payloadDeferred ||
721
+ response.status !== 200 ||
722
+ !response.headers.get("content-type")?.startsWith("text/x-component") ||
723
+ FORBIDS_STORE.test(response.headers.get("cache-control") ?? "") ||
724
+ carriesClientState(response, reqCtx)
725
+ ) {
726
+ return;
727
+ }
728
+ const cacheControl = storedPrefetchCacheControl(router.prefetchCacheTTL);
729
+ if (cacheControl) trySetHeader(response, "cache-control", cacheControl);
730
+ }
731
+
732
+ /**
733
+ * A Cache-Control directive that forbids reusing the response unvalidated:
734
+ * `no-store`, `no-cache`, or a zero `max-age` (`s-maxage` is the shared
735
+ * caches' and does not match). The route asked for a fresh answer each time;
736
+ * the router's `max-age` would override it.
737
+ */
738
+ const FORBIDS_STORE =
739
+ /(^|[\s,;])(no-store|no-cache|max-age=["']?0+["']?)(?=$|[\s,;=])/i;
740
+
670
741
  /**
671
742
  * The response the global chain returned, made ready to leave: a partial or
672
743
  * action request's redirect converted, onResponse callbacks drained, the
@@ -679,6 +750,7 @@ export function finalizeRequestResponse<TEnv>(
679
750
  const { ctx, reqCtx, request, url, env } = opts;
680
751
  const router = ctx.router;
681
752
  let response: Response;
753
+ let redirect = false;
682
754
  if (opts.hasMiddleware) {
683
755
  if (
684
756
  url.searchParams.has(WIRE.partial) ||
@@ -689,6 +761,7 @@ export function finalizeRequestResponse<TEnv>(
689
761
  ctx.createRedirectFlightResponse,
690
762
  { requestOrigin: url.origin, basename: router.basename },
691
763
  );
764
+ redirect = intercepted !== null;
692
765
  response = intercepted ?? finalizeResponse(downstream);
693
766
  } else {
694
767
  response = finalizeResponse(downstream);
@@ -700,6 +773,7 @@ export function finalizeRequestResponse<TEnv>(
700
773
  if (opts.rewrittenTo !== undefined) {
701
774
  trySetHeader(response, REWRITE_HEADER, opts.rewrittenTo);
702
775
  }
776
+ applyStoredPrefetchCacheControl(response, reqCtx, url, router, redirect);
703
777
  opts.beforeGuard?.(response);
704
778
 
705
779
  // Single open-redirect chokepoint: every response (PE, full-page,
@@ -707,7 +781,19 @@ export function finalizeRequestResponse<TEnv>(
707
781
  // guarding browser-followed (3xx) redirects once covers them all and any
708
782
  // future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
709
783
  // through untouched (validated client-side instead).
710
- const guarded = guardOutgoingRedirect(response, url.origin, router.basename);
784
+ // After the guard, so the cookie names the target the browser follows.
785
+ // A document carrying location state, or a redirect carrying it on, leaves
786
+ // private: set after every middleware, so none can make it shared.
787
+ const guarded = markDocumentResponsePrivate(
788
+ carryLocationStateAcrossRedirect(
789
+ guardOutgoingRedirect(response, url.origin, router.basename),
790
+ reqCtx,
791
+ request,
792
+ url,
793
+ router.resolvedStateCookieName,
794
+ ),
795
+ reqCtx,
796
+ );
711
797
 
712
798
  // Stream-idle watchdog (opt-in via timeouts.streamIdleMs): bounds
713
799
  // end-to-end idle flow on the streamed body — see rsc/stream-idle.ts
@@ -102,6 +102,7 @@ import { contextGet } from "../context-var.js";
102
102
  import { rewriteShellAudience, rewriteTrail } from "../server/rewrite-trail.js";
103
103
  import { visitorShellKey } from "./shell-capture-constants.js";
104
104
  import { PREFETCH_HEADER } from "../prefetch-markers.js";
105
+ import { isPrefetchKind } from "../router/segment-resolution/prefetch-deferral.js";
105
106
  import { WIRE } from "../wire-params.js";
106
107
  import {
107
108
  isWarmReplace,
@@ -140,6 +141,7 @@ import {
140
141
  SEGMENT_FRAGMENT_CAPABILITY_HEADER,
141
142
  SEGMENT_FRAGMENT_RECOVERY_HEADER,
142
143
  } from "../segment-fragments.js";
144
+ import { prefetchScopeOf } from "../router/source-reads.js";
143
145
 
144
146
  type PprReplayStatus =
145
147
  | { outcome: "HIT"; freshness: "fresh" | "stale" }
@@ -361,10 +363,10 @@ type PreparedRender =
361
363
  kind: "payload";
362
364
  payload: RscPayload;
363
365
  /**
364
- * The response depends on the source page: an intercept targets the
365
- * route, or it carries deferred units (`prefetch: false`).
366
+ * `x-rsc-prefetch-scope`, when the response depends on the source
367
+ * page (prefetchScopeOf).
366
368
  */
367
- sourceScoped: boolean;
369
+ prefetchScope: string | undefined;
368
370
  pprReplayStatus?: PprReplayStatus;
369
371
  /** The key a navigation-only heal capture stores under, when one is needed. */
370
372
  healKey?: string;
@@ -430,6 +432,12 @@ function* requestRenderPlan<TEnv>(
430
432
  // chain (executeRender wraps it), so no shell byte can precede a guard
431
433
  // decision — that ordering is what makes a shared shell safe. Routes
432
434
  // without the `ppr` option stay pure axis 1 at zero cost.
435
+ //
436
+ // Location state set by now (middleware, a document redirect's cookie) does
437
+ // not keep a request off the shell: a HIT attaches it to the live tail's
438
+ // payload, and a capture builds its own payload without it, so no shell
439
+ // ever holds a visitor's state (server/document-location-state.ts).
440
+ const stateBeforeRender = reqCtx._locationState?.length ?? 0;
433
441
  const shell = yield* scope("shell-serve", shellServePlan(input));
434
442
  if (shell.kind === "serve") return shell.response;
435
443
 
@@ -441,11 +449,12 @@ function* requestRenderPlan<TEnv>(
441
449
  );
442
450
  if (prepared.kind === "control") return prepared.response;
443
451
 
444
- // For partial requests, include any server-set location state in the payload.
445
- // SSR (full page) requests ignore location state since there's no history.state
446
- // to write to on a fresh page load.
447
- if (isPartial && prepared.payload.metadata) {
448
- attachLocationStateIfPresent(prepared.payload);
452
+ // Server-set location state rides the payload: a navigation's, merged at
453
+ // its commit, or a document's bootstrap payload, merged when the client
454
+ // starts (browser/rsc-router.tsx). A document carrying it is per visitor
455
+ // (server/document-location-state.ts).
456
+ if (prepared.payload.metadata) {
457
+ attachLocationStateIfPresent(prepared.payload, !isPartial);
449
458
  }
450
459
 
451
460
  const response = yield* step("render", () =>
@@ -457,8 +466,14 @@ function* requestRenderPlan<TEnv>(
457
466
  // shell). Capture does not flow through the HTTP pipeline — middleware never
458
467
  // re-runs (it already ran for this request; guarding is serve-time).
459
468
  // reqCtx._dynamic is re-read here on purpose: a loader may have marked the
460
- // request dynamic while rendering.
461
- if (shell.kind === "miss" && !reqCtx._dynamic) {
469
+ // request dynamic while rendering. State a handler set in this render is
470
+ // not captured: a HIT replays the handler's output without running it, so
471
+ // the state would be lost on every HIT. The route renders in full instead.
472
+ if (
473
+ shell.kind === "miss" &&
474
+ !reqCtx._dynamic &&
475
+ (reqCtx._locationState?.length ?? 0) <= stateBeforeRender
476
+ ) {
462
477
  if (
463
478
  response.status === 200 &&
464
479
  (response.headers.get("content-type") ?? "").includes("text/html")
@@ -844,7 +859,7 @@ function* preparePayloadPlan<TEnv>(
844
859
  return {
845
860
  kind: "payload",
846
861
  payload: buildFullPayload(match, ctx, url, reqCtx, handleStore),
847
- sourceScoped: false,
862
+ prefetchScope: undefined,
848
863
  pprReplayStatus,
849
864
  healKey,
850
865
  };
@@ -856,6 +871,7 @@ function* preparePayloadPlan<TEnv>(
856
871
  // showed at once, and ran held work again.
857
872
  const deferring = result.segments.some((s) => s.deferred);
858
873
  if (deferring) reqCtx._payloadDeferred = true;
874
+ if (result.fromStorage) reqCtx._servedFromStorage = true;
859
875
  return {
860
876
  kind: "payload",
861
877
  payload: {
@@ -885,7 +901,11 @@ function* preparePayloadPlan<TEnv>(
885
901
  stateCookieName: ctx.router.resolvedStateCookieName,
886
902
  },
887
903
  },
888
- sourceScoped: result.interceptTargeted === true || deferring,
904
+ prefetchScope: prefetchScopeOf(
905
+ result.interceptTargeted === true || deferring,
906
+ result.sourceHeld,
907
+ result.diff.length === 0,
908
+ ),
889
909
  pprReplayStatus,
890
910
  healKey,
891
911
  };
@@ -909,7 +929,7 @@ function* preparePayloadPlan<TEnv>(
909
929
  return {
910
930
  kind: "payload",
911
931
  payload: buildFullPayload(match, ctx, url, reqCtx, handleStore),
912
- sourceScoped: false,
932
+ prefetchScope: undefined,
913
933
  };
914
934
  }
915
935
 
@@ -1095,7 +1115,7 @@ function renderPreparedRscResponse<TEnv>(
1095
1115
  ): Promise<Response> {
1096
1116
  const { ctx, request, env, url, isPartial, nonce, reqCtx, renderSpan } =
1097
1117
  input;
1098
- const { payload, pprReplayStatus, sourceScoped } = prepared;
1118
+ const { payload, pprReplayStatus, prefetchScope } = prepared;
1099
1119
  const metricsStore = reqCtx._metricsStore;
1100
1120
 
1101
1121
  const rscHeaders: Record<string, string> = {
@@ -1112,22 +1132,24 @@ function renderPreparedRscResponse<TEnv>(
1112
1132
  serializePprReplayStatus(pprReplayStatus);
1113
1133
  }
1114
1134
  // Tell the client's prefetch cache to scope this response to its source
1115
- // URL (instead of the default source-agnostic wildcard). A route an
1116
- // intercept targets renders the modal or the full page depending on the
1117
- // source, so neither response may be reused from another source: a full
1118
- // page prefetched where the intercept does not apply would otherwise
1119
- // serve a later click where it does (#1007).
1120
- if (sourceScoped) {
1121
- rscHeaders["x-rsc-prefetch-scope"] = "source";
1135
+ // (instead of the default source-agnostic wildcard). See prefetchScopeOf.
1136
+ if (prefetchScope !== undefined) {
1137
+ rscHeaders["x-rsc-prefetch-scope"] = prefetchScope;
1138
+ // A shared cache keys a partial by URL and the ids the client holds, not
1139
+ // by its source page (document-cache.ts): stored, a scoped answer would
1140
+ // serve its diff to another page that holds the same ids.
1141
+ reqCtx._payloadSourceScoped = true;
1122
1142
  }
1123
- // prefetch: false (docs/design/prefetch-false.md, R7). A response that
1124
- // carries deferred units answers one page's prefetch, and a fill is the
1125
- // second half of one adoption: neither is reused by a shared cache or the
1126
- // document cache (which also refuses a payload marked `_payloadDeferred`),
1127
- // whatever Cache-Control the route set; so is a stale partial render, which
1128
- // a shared cache keyed by URL must not store or serve. A route with no flag
1129
- // keeps its headers.
1143
+ // Not reused by a shared cache or the document cache (which also refuses a
1144
+ // payload marked `_payloadDeferred` or `_payloadSourceScoped`), whatever
1145
+ // Cache-Control the route set: a partial scoped to its source page (above);
1146
+ // a response that carries deferred units, which answers one page's prefetch,
1147
+ // and a fill, the second half of one adoption (prefetch: false,
1148
+ // docs/design/prefetch-false.md, R7); and a stale partial render, which a
1149
+ // shared cache keyed by URL must not store or serve. Any other route keeps
1150
+ // its headers.
1130
1151
  if (
1152
+ reqCtx._payloadSourceScoped ||
1131
1153
  reqCtx._payloadDeferred ||
1132
1154
  (isPartial &&
1133
1155
  (reqCtx._requestKind === "fill" ||
@@ -1177,7 +1199,7 @@ function renderPreparedRscResponse<TEnv>(
1177
1199
  },
1178
1200
  );
1179
1201
  // A prefetch whose render reported an error is not kept (#1170).
1180
- if (reqCtx._requestKind !== "prefetch" || !isPartial) return rendered;
1202
+ if (!isPrefetchKind(reqCtx._requestKind) || !isPartial) return rendered;
1181
1203
  return rendered.then((response) =>
1182
1204
  markPrefetchOnRenderError(response, reqCtx._renderErrors ?? []),
1183
1205
  );
@@ -1712,6 +1734,21 @@ function serveShellHit(
1712
1734
  // A visitor-keyed shell shows the visitor's URL: the document cache keys
1713
1735
  // this response by the target.
1714
1736
  if (entry.pathname !== undefined) reqCtx._payloadVisitorLocation = true;
1737
+ // Location state set before the render (middleware, a document redirect's
1738
+ // cookie) rides the tail's payload, not the shell. Marked here, on the
1739
+ // request's own context and before the response leaves (the tail runs on a
1740
+ // derived context, after an await), so the response goes
1741
+ // `private, no-store` and stays out of the document cache
1742
+ // (server/document-location-state.ts). The tail carries exactly this
1743
+ // state: a write during the tail (a loader's, through getRequestContext())
1744
+ // comes after these headers chose the response's caching, so it is not
1745
+ // sent, as the skill documents (skills/hooks/state.md).
1746
+ // Never undefined: attachLocationStateIfPresent would read the tail's
1747
+ // context instead.
1748
+ const tailLocationState = reqCtx._locationState ?? [];
1749
+ if (tailLocationState.length > 0) {
1750
+ reqCtx._documentLocationState = true;
1751
+ }
1715
1752
  const metricsStore = reqCtx._metricsStore;
1716
1753
  // Per-stage tail timing for the `ppr:tail` Server-Timing mirror and the
1717
1754
  // perf console line: always in dev, in production only when this request
@@ -1739,7 +1776,7 @@ function serveShellHit(
1739
1776
  reportCacheError(error, "cache-write", "[ShellServe] retire");
1740
1777
  }
1741
1778
  };
1742
- /** The first loader pin of the entry that failed to decode (dc-t89). */
1779
+ /** The first loader pin of the entry that failed to decode. */
1743
1780
  let undecodablePin: string | undefined;
1744
1781
 
1745
1782
  const renderTail = async (
@@ -1768,6 +1805,9 @@ function serveShellHit(
1768
1805
  | undefined;
1769
1806
  // Same for the location: the key's URL, or a visitor-keyed shell's.
1770
1807
  payload.metadata.pathname = entry.pathname ?? url.pathname;
1808
+ // The visitor's location state, as a MISS document's payload carries
1809
+ // it. The request was marked per visitor above.
1810
+ attachLocationStateIfPresent(payload, false, tailLocationState);
1771
1811
  }
1772
1812
  // Full Flight render per request: hydration needs the whole payload (there
1773
1813
  // is no Flight-side resume — a React limitation, not ours).
@@ -2031,7 +2071,13 @@ function serveShellHit(
2031
2071
  "the doc record could not supply the handler layer",
2032
2072
  );
2033
2073
  }
2034
- controller.enqueue(new TextEncoder().encode(shellReloadScript()));
2074
+ // A document redirect's state cookie: this response's headers
2075
+ // cleared it, and the reload must read it again.
2076
+ controller.enqueue(
2077
+ new TextEncoder().encode(
2078
+ shellReloadScript(reqCtx._carriedLocationStateCookie),
2079
+ ),
2080
+ );
2035
2081
  if (tailTiming) {
2036
2082
  tailTiming.outcome = "error";
2037
2083
  tailTiming.completeMs = Math.round(performance.now() - tailT0);
@@ -2057,7 +2103,7 @@ function serveShellHit(
2057
2103
  }
2058
2104
  // A pin that failed to decode left its loader to this request's run:
2059
2105
  // this response hydrated once over a prelude rendered from the
2060
- // capture's value (dc-t89). Retire the entry once the response is
2106
+ // capture's value. Retire the entry once the response is
2061
2107
  // closed, and back the key off so a pin that keeps failing costs one
2062
2108
  // mismatch per window. No reload: that would cost a second page load.
2063
2109
  if (undecodablePin !== undefined) {