@rangojs/router 0.17.0 → 0.19.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 (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -5,6 +5,7 @@
5
5
  * (initial page load) requests.
6
6
  */
7
7
 
8
+ import { requestHeaders } from "../server/request-headers.js";
8
9
  import {
9
10
  getRequestContext,
10
11
  setRequestContextParams,
@@ -13,15 +14,20 @@ import {
13
14
  } from "../server/request-context.js";
14
15
  import {
15
16
  SeededShellStore,
17
+ ShellRecordUnavailableError,
16
18
  buildShellLoaderSeed,
19
+ countSnapshotFamilies,
20
+ hasDocRecord,
17
21
  } from "../cache/shell-snapshot.js";
18
22
  import { appendMetric } from "../router/metrics.js";
23
+ import type { MetricsStore } from "../server/context.js";
19
24
  import { observePhase, PHASES } from "../router/instrument.js";
20
25
  import type { TraceSpan } from "../router/tracing.js";
21
26
  import { getSSRSetup, createSsrHtmlStage, isRscRequest } from "./ssr-setup.js";
22
27
  import type { RscPayload } from "./types.js";
23
28
  import type { SSRModule } from "./types.js";
24
29
  import type { RequestContext } from "../server/request-context.js";
30
+ import type { PprReplayBypassReason } from "./shell-capture-constants.js";
25
31
  import {
26
32
  createResponseWithMergedHeaders,
27
33
  createSimpleRedirectResponse,
@@ -42,9 +48,12 @@ import {
42
48
  type RoutinePlan,
43
49
  } from "./routine-plan.js";
44
50
  import type { HandlerContext } from "./handler-context.js";
45
- import { gateTransitions } from "./transition-gate.js";
51
+ import { attachTransitionWhen } from "./attach-transition-when.js";
52
+ import { publicRouteName } from "../route-name.js";
46
53
  import { buildFullPayload } from "./full-payload.js";
47
54
  import {
55
+ capShellWindow,
56
+ lastStoredCaptureSeq,
48
57
  scheduleShellCapture,
49
58
  resolveShellCaptureDebugSink,
50
59
  takeCaptureDebugEventForTiming,
@@ -59,7 +68,15 @@ import {
59
68
  shellSearchSeed,
60
69
  isValidShellHit,
61
70
  hasIntactShellPayload,
62
- base64ToBytes,
71
+ openShellDocument,
72
+ navigationShellKey,
73
+ partitionShellKey,
74
+ inlineShellScript,
75
+ shellReloadScript,
76
+ partitionBuildShellCheckDone,
77
+ notePartitionBuildShellCheck,
78
+ warnPprWindowCappedOnce,
79
+ SHELL_PRELUDE_CHUNK_BYTES,
63
80
  hasShellFamily,
64
81
  warnShellStoreMissingOnce,
65
82
  warnPprNonceActiveOnce,
@@ -67,13 +84,19 @@ import {
67
84
  publishShellTailTiming,
68
85
  takeShellTailTimingForServerTiming,
69
86
  type ResolvedPprConfig,
87
+ type ShellDocument,
70
88
  type ShellTailTiming,
71
89
  } from "./shell-serve.js";
72
90
  import {
91
+ hasBuildShell,
73
92
  lookupBuildShell,
74
93
  type DevShellLookup,
75
94
  } from "./shell-build-manifest.js";
76
95
  import { contextGet } from "../context-var.js";
96
+ import {
97
+ resolveSwrWindow,
98
+ staleShellRecaptureDue,
99
+ } from "../cache/cache-policy.js";
77
100
  import {
78
101
  getNavigationContextHeader,
79
102
  prerenderStoreShortCircuits,
@@ -88,13 +111,17 @@ import {
88
111
  safeSameOriginLanding,
89
112
  } from "../redirect-origin.js";
90
113
  import { nonce as nonceToken } from "./nonce.js";
114
+ import { escapeJsonForScript } from "../escape-script.js";
115
+ import { resolveShellPartition } from "../cache/cache-scope.js";
91
116
  import { reportCacheError } from "../cache/cache-error.js";
92
117
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
93
118
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
94
119
  import type {
95
- DocumentShellCacheEntry,
96
120
  SegmentCacheStore,
97
121
  ShellCacheEntry,
122
+ ShellDocumentRead,
123
+ ShellEntryHead,
124
+ ShellReadStats,
98
125
  ShellSnapshotRecord,
99
126
  } from "../cache/types.js";
100
127
  import {
@@ -102,27 +129,6 @@ import {
102
129
  SEGMENT_FRAGMENT_RECOVERY_HEADER,
103
130
  } from "../segment-fragments.js";
104
131
 
105
- type PprReplayBypassReason =
106
- | "method"
107
- | "dynamic"
108
- | "nonce"
109
- | "store-unavailable"
110
- | "passive-read-unsupported"
111
- | "no-navigation-context"
112
- | "prerender-store"
113
- | "intercept"
114
- | "cache-disabled"
115
- | "read-error"
116
- | "no-entry"
117
- | "invalid-version"
118
- | "corrupt-entry"
119
- | "handler-live-holes"
120
- | "transition-when"
121
- | "no-segment-snapshot"
122
- | "snapshot-miss"
123
- | "explicit-cache-hit"
124
- | "stale-build-entry";
125
-
126
132
  type PprReplayStatus =
127
133
  | { outcome: "HIT"; freshness: "fresh" | "stale" }
128
134
  | { outcome: "BYPASS"; reason: PprReplayBypassReason };
@@ -143,13 +149,6 @@ function describePprReplayStatus(status: PprReplayStatus): string {
143
149
  : `bypass:${status.reason}`;
144
150
  }
145
151
 
146
- function buildNavigationShellKey(
147
- url: URL,
148
- filter?: SearchParamsFilter,
149
- ): string {
150
- return `${buildShellKey(url, filter)}:navigation`;
151
- }
152
-
153
152
  function createShellCaptureDescriptor(
154
153
  ctx: HandlerContext<any>,
155
154
  key: string,
@@ -173,7 +172,6 @@ function createShellCaptureDescriptor(
173
172
  tags: pprConfig.tags,
174
173
  captureTimeout: pprConfig.captureTimeout,
175
174
  store,
176
- debug: INTERNAL_RANGO_DEBUG,
177
175
  maxSnapshotBytes: pprConfig.maxSnapshotBytes,
178
176
  debugSink: resolveShellCaptureDebugSink(ctx.router.debugShellCapture),
179
177
  navigationOnly,
@@ -186,8 +184,7 @@ function shouldHealReplayMiss(
186
184
  return (
187
185
  reason === undefined ||
188
186
  reason === "invalid-version" ||
189
- reason === "corrupt-entry" ||
190
- reason === "stale-build-entry"
187
+ reason === "corrupt-entry"
191
188
  );
192
189
  }
193
190
 
@@ -242,35 +239,11 @@ function replayableShellSnapshot(
242
239
  if (!entry.navigationOnly && !hasIntactShellPayload(entry)) {
243
240
  return { reason: "corrupt-entry" };
244
241
  }
245
- if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
246
- if (entry.transitionWhen) return { reason: "transition-when" };
247
- // Eligibility requires the CANONICAL doc segment record (`docKey`), not just
248
- // any segment record: captures under an explicit cache() scope also record
249
- // that tier's reads/writes under keys a partial lookup can never resolve,
250
- // and counting those declared entries "replayable" that then always missed
251
- // (a timing-dependent snapshot-miss/no-segment-snapshot flip-flop). Entries
252
- // captured before the field existed read as no-segment-snapshot and heal on
253
- // recapture (pre-release, same treatment as the buildVersion field).
254
- const docKey = entry.docKey;
255
- const snapshot = entry.snapshot;
256
- const hasDocRecord =
257
- docKey !== undefined &&
258
- snapshot?.some((record) => {
259
- if (
260
- !record ||
261
- typeof record !== "object" ||
262
- record.family !== "segment" ||
263
- record.key !== docKey ||
264
- typeof record.value !== "object" ||
265
- record.value === null
266
- ) {
267
- return false;
268
- }
269
- const segments = (record.value as { segments?: unknown }).segments;
270
- return Array.isArray(segments) && segments.length > 0;
271
- });
272
- return hasDocRecord && snapshot
273
- ? { snapshot }
242
+ // Eligibility requires the CANONICAL doc segment record (`docKey`): replay
243
+ // resolves that record and nothing else. An entry without it (a
244
+ // prerender-served capture, a tombstone) reads as no-segment-snapshot.
245
+ return hasDocRecord(entry.snapshot, entry.docKey)
246
+ ? { snapshot: entry.snapshot }
274
247
  : { reason: "no-segment-snapshot" };
275
248
  }
276
249
 
@@ -324,10 +297,11 @@ type PreparedRender =
324
297
  | {
325
298
  kind: "payload";
326
299
  payload: RscPayload;
327
- hasInterceptSlots: boolean;
300
+ /** The response depends on the source page: an intercept targets the route. */
301
+ sourceScoped: boolean;
328
302
  pprReplayStatus?: PprReplayStatus;
329
- partialCaptureNeeded: boolean;
330
- partialCaptureKey?: string;
303
+ /** The key a navigation-only heal capture stores under, when one is needed. */
304
+ healKey?: string;
331
305
  };
332
306
 
333
307
  async function handleRscRenderingInner<TEnv>(
@@ -439,21 +413,47 @@ function* requestRenderPlan<TEnv>(
439
413
  response.headers.set(SHELL_STATUS_HEADER, "MISS");
440
414
  }
441
415
 
416
+ const healKey = prepared.healKey;
442
417
  if (
443
418
  isPartial &&
444
- prepared.partialCaptureNeeded &&
419
+ healKey !== undefined &&
445
420
  !reqCtx._dynamic &&
446
421
  response.status === 200
447
422
  ) {
448
- const captureKey = prepared.partialCaptureKey;
449
423
  yield* handoff("navigation-shell-capture", () =>
450
- scheduleNavigationShellCapture(input, captureKey),
424
+ scheduleNavigationShellCapture(input, healKey),
451
425
  );
452
426
  }
453
427
 
454
428
  return response;
455
429
  }
456
430
 
431
+ /**
432
+ * Dev: warn once per route when the route's cache() entry reduces an explicit
433
+ * ppr window. The window is what capShellWindow gives a capture from a
434
+ * record written just now, the widest it can get; null (a cache() ttl + swr
435
+ * of 0) is the capture's own warning.
436
+ */
437
+ function warnIfPprWindowReduced(
438
+ routeName: string,
439
+ pprOption: { ttl?: number; swr?: number },
440
+ pprConfig: ResolvedPprConfig,
441
+ cacheScope: { ttl: number; swr: number | undefined },
442
+ store: SegmentCacheStore<any>,
443
+ ): void {
444
+ const cache = { ttl: cacheScope.ttl, swr: cacheScope.swr ?? 0 };
445
+ const shell = capShellWindow(
446
+ pprConfig.ttl,
447
+ resolveSwrWindow(pprConfig.swr, store.defaults),
448
+ {
449
+ freshUntil: cache.ttl * 1000,
450
+ staleUntil: (cache.ttl + cache.swr) * 1000,
451
+ },
452
+ 0,
453
+ );
454
+ if (shell) warnPprWindowCappedOnce(routeName, pprOption, shell, cache);
455
+ }
456
+
457
457
  /**
458
458
  * Axis 2 shell serve. Sync gates stay plan code; store and SSR effects run as
459
459
  * named steps. Outcomes: serve a composed shell response, report a MISS (the
@@ -474,36 +474,88 @@ function* shellServePlan<TEnv>(
474
474
  ) {
475
475
  return { kind: "pass" };
476
476
  }
477
- const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
477
+ const manifestEntry = reqCtx._classifiedRoute?.manifestEntry;
478
+ const pprConfig = resolvePprConfig(manifestEntry);
478
479
  if (!pprConfig) return { kind: "pass" };
480
+ // A degraded HIT's reload (shellReloadScript) renders like a cache miss:
481
+ // no shell read, no capture, so it can never degrade again. The handler
482
+ // stripped the marker from the request and left this flag.
483
+ if (reqCtx._shellForcedMiss) return { kind: "pass" };
479
484
 
480
485
  // A per-request CSP nonce pins the route to axis 1: a shared shell would
481
486
  // freeze the capture request's nonce and CSP would reject it for every
482
- // other visitor. BOTH nonce sources must gate — the createRouter({ nonce })
483
- // provider (`nonce` param) and a middleware ctx.set(nonce, …) token write;
484
- // the provider-only check missed the latter (issue #656).
485
- const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
487
+ // other visitor. Every step below, a HIT included, therefore runs without
488
+ // one.
486
489
  const store = reqCtx._cacheStore;
487
- const key = buildShellKey(url, reqCtx._searchParamsFilter);
488
- mirrorPprServerTimingsForDev(key, reqCtx);
489
- if (activeNonce !== undefined) {
490
+ const baseKey = buildShellKey(url, reqCtx._searchParamsFilter);
491
+ if (activeRequestNonce(nonce, reqCtx) !== undefined) {
490
492
  // Declared intent that cannot be honored deserves a diagnostic (unlike an
491
493
  // undeclared route, which is silent): a ppr route gated off by an active
492
494
  // per-request nonce warns once per key. Axis 1 after the warning.
493
- warnPprNonceActiveOnce(key);
495
+ warnPprNonceActiveOnce(baseKey);
494
496
  return { kind: "pass" };
495
497
  }
496
498
  if (!hasShellFamily(store)) {
497
499
  // Declared intent that cannot be honored deserves a diagnostic (unlike an
498
500
  // undeclared route, which is silent). Axis 1 after the warning.
499
- warnShellStoreMissingOnce(key);
501
+ warnShellStoreMissingOnce(baseKey);
500
502
  return { kind: "pass" };
501
503
  }
502
-
504
+ // The route's own cache() opt-out — cache(false), or a condition() that
505
+ // refuses THIS request — is absolute, and a HIT tail never consults the
506
+ // route scope (it replays the shell's doc record), so it is decided here,
507
+ // before any shell byte: the request renders like a cache miss (axis 1) and
508
+ // schedules no capture (the capture could not record the handler layer).
509
+ const route = classifiedRouteSnapshot(reqCtx);
510
+ if (route?.cacheScope && !route.cacheScope.allowsCache("read")) {
511
+ return { kind: "pass" };
512
+ }
513
+ // The shell is capped to the route cache() entry it captures from
514
+ // (capShellWindow); an explicit ppr window the cap reduces warns in dev.
515
+ // resolvePprConfig returned a config, so the entry is a ppr route.
516
+ const pprOption = manifestEntry?.type === "route" && manifestEntry.ppr;
517
+ if (
518
+ process.env.NODE_ENV !== "production" &&
519
+ route?.cacheScope?.enabled &&
520
+ typeof pprOption === "object" &&
521
+ (pprOption.ttl !== undefined || pprOption.swr !== undefined)
522
+ ) {
523
+ warnIfPprWindowReduced(
524
+ reqCtx._classifiedRoute?.routeKey ?? url.pathname,
525
+ pprOption,
526
+ pprConfig,
527
+ route.cacheScope,
528
+ store,
529
+ );
530
+ }
531
+ // A route whose cache() record is partitioned by the request (its `key()`,
532
+ // or the store's keyGenerator) partitions its shell the same way: each
533
+ // partition captures and serves its own shell. A failed key resolution
534
+ // serves no shell, never another partition's.
535
+ const requestKey = resolveRequestShellKey(
536
+ baseKey,
537
+ route,
538
+ store,
539
+ reqCtx,
540
+ "ShellServe",
541
+ );
542
+ const key =
543
+ typeof requestKey === "string"
544
+ ? requestKey
545
+ : yield* step("shell-partition", () => requestKey);
546
+ if (key === undefined) return { kind: "pass" };
547
+ reqCtx._shellKey = key;
548
+ // The buffered capture and tail timings are keyed by the partitioned key.
549
+ mirrorPprServerTimings(key, reqCtx);
550
+
551
+ // A MISS schedules its capture after rendering: taken before the read, this
552
+ // lets that capture skip itself when another request's capture stored the
553
+ // shell in between (ShellCaptureDescriptor.storedSeqAtRead).
554
+ const storedSeqAtRead = lastStoredCaptureSeq(key);
503
555
  // SSR setup starts before route handling, so read the shell before joining it
504
556
  // to overlap the remaining setup work with cache I/O.
505
557
  const cached = yield* step("shell-read", () =>
506
- readShellEntry(store, key, reqCtx),
558
+ readShellEntry(store, key, reqCtx, pprConfig.tags),
507
559
  );
508
560
 
509
561
  // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
@@ -527,13 +579,26 @@ function* shellServePlan<TEnv>(
527
579
  pprConfig,
528
580
  store,
529
581
  );
582
+ // MISS (no entry, invalid reactVersion, a tombstone, or store read
583
+ // failure): axis 1 + a background capture scheduled once the response is
584
+ // known servable.
585
+ const miss: ShellServeOutcome = {
586
+ kind: "miss",
587
+ descriptor: { ...descriptor, storedSeqAtRead },
588
+ ssrModule,
589
+ };
530
590
 
591
+ // A HIT replays the handler layer from the entry's doc record (`docKey`),
592
+ // or, for a Prerender route, from the prerender store. An entry that has
593
+ // neither is a MISS: serving it would run handlers after the commit.
531
594
  if (
532
595
  cached &&
533
596
  !cached.entry.navigationOnly &&
597
+ (cached.entry.docKey !== undefined || route?.matched?.pr === true) &&
534
598
  isValidShellHit(cached.entry, ctx.version)
535
599
  ) {
536
- if (!hasIntactShellPayload(cached.entry)) {
600
+ const document = openShellDocumentMetered(reqCtx, cached);
601
+ if (!document) {
537
602
  // Corrupt stored payload (undecodable prelude / unparseable
538
603
  // postponed): a store-layer fault worth a diagnostic, unlike the
539
604
  // silent version-mismatch lifecycle misses above. Degrade to MISS
@@ -548,8 +613,12 @@ function* shellServePlan<TEnv>(
548
613
  );
549
614
  } else {
550
615
  // Stale (SWR) hit: serve the stale shell now, recapture in the
551
- // background (stampede-guarded + backoff inside scheduleShellCapture).
552
- if (cached.shouldRevalidate) {
616
+ // background (stampede-guarded + backoff inside scheduleShellCapture),
617
+ // at most once per SHELL_MIN_RECAPTURE_INTERVAL_MS of the shell's age.
618
+ if (
619
+ cached.shouldRevalidate &&
620
+ staleShellRecaptureDue(cached.entry, Date.now())
621
+ ) {
553
622
  yield* handoff("shell-recapture", () =>
554
623
  scheduleShellCapture(
555
624
  ctx,
@@ -562,7 +631,6 @@ function* shellServePlan<TEnv>(
562
631
  ),
563
632
  );
564
633
  }
565
- const entry = cached.entry;
566
634
  const response = yield* step("shell-hit", () =>
567
635
  serveShellHit(
568
636
  ctx,
@@ -572,7 +640,7 @@ function* shellServePlan<TEnv>(
572
640
  reqCtx,
573
641
  handleStore,
574
642
  ssrModule,
575
- entry,
643
+ document,
576
644
  descriptor,
577
645
  ),
578
646
  );
@@ -580,26 +648,63 @@ function* shellServePlan<TEnv>(
580
648
  }
581
649
  }
582
650
 
651
+ // A runtime tombstone (degradeUnreplayableShell: this key's HIT could not
652
+ // replay its handler layer) shadows the build shell too, or a build shell
653
+ // whose tail cannot be replayed would degrade on every request until a
654
+ // runtime capture lands.
655
+ if (
656
+ cached?.entry.navigationOnly &&
657
+ isValidShellHit(cached.entry, ctx.version)
658
+ ) {
659
+ return miss;
660
+ }
661
+
583
662
  // Build-time shell read-through (producer B, #699): on a runtime
584
663
  // store MISS a Prerender+ppr route serves its `vite build`-baked
585
- // shell through the SAME serve path. lookupBuildShell owns every
586
- // gate and fails to null (ordinary MISS path takes over); past
587
- // ppr.ttl the baked entry still serves while SWR recaptures — the
588
- // upgrade path from build entry to runtime entry.
589
- const buildHit = yield* step("build-shell-lookup", () =>
590
- lookupBuildShell(
591
- url,
592
- ctx.version,
593
- store,
594
- // Dev: no build manifest exists; producer B runs on demand via
595
- // the dev server's /__rsc_shell endpoint for PRERENDERED routes
596
- // only (production's exact candidate set). Folded away in
597
- // production builds (NODE_ENV is a compile-time constant).
598
- resolveDevShellLookup(reqCtx, pprConfig),
599
- reqCtx._searchParamsFilter,
600
- ),
601
- );
602
- if (buildHit) {
664
+ // shell through the SAME serve path. A partitioned route has none: the
665
+ // build captured one partition, which no request may be assumed to
666
+ // share, and a route that has a build shell says so once. lookupBuildShell
667
+ // owns the validity gates and fails to null; openShellDocument below is
668
+ // the prelude's integrity check and only decode (either failing leaves the
669
+ // ordinary MISS path); past ppr.ttl the baked entry still serves while SWR
670
+ // recaptures — the upgrade path from build entry to runtime entry.
671
+ //
672
+ // Dev: no build manifest exists; producer B runs on demand via the dev
673
+ // server's /__rsc_shell endpoint for PRERENDERED routes only (production's
674
+ // exact candidate set). Folded away in production builds (NODE_ENV is a
675
+ // compile-time constant).
676
+ const devShellLookup = resolveDevShellLookup(reqCtx, pprConfig);
677
+ let buildHit: Awaited<ReturnType<typeof lookupBuildShell>> = null;
678
+ if (key !== baseKey) {
679
+ // Probed once per path without a build shell, and not again once the
680
+ // route warned. A search-bearing URL never reads one: nothing to probe.
681
+ const routeName = reqCtx._classifiedRoute?.routeKey;
682
+ if (
683
+ !partitionBuildShellCheckDone(url.pathname, routeName) &&
684
+ shellSearchSeed(url, reqCtx._searchParamsFilter) === ""
685
+ ) {
686
+ const found = yield* step("build-shell-check", () =>
687
+ hasBuildShell(url.pathname, devShellLookup),
688
+ );
689
+ notePartitionBuildShellCheck(url.pathname, routeName, found);
690
+ }
691
+ } else {
692
+ buildHit = yield* step("build-shell-lookup", () =>
693
+ lookupBuildShell(
694
+ url,
695
+ ctx.version,
696
+ store,
697
+ devShellLookup,
698
+ reqCtx._searchParamsFilter,
699
+ ),
700
+ );
701
+ }
702
+ // The document serve decodes the baked prelude once here; an undecodable
703
+ // one is a MISS like a corrupt runtime entry.
704
+ const buildDocument = buildHit
705
+ ? openShellDocumentMetered(reqCtx, { entry: buildHit.entry })
706
+ : null;
707
+ if (buildHit && buildDocument) {
603
708
  // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
604
709
  if (buildHit.stale) {
605
710
  yield* handoff("shell-recapture", () =>
@@ -623,16 +728,14 @@ function* shellServePlan<TEnv>(
623
728
  reqCtx,
624
729
  handleStore,
625
730
  ssrModule,
626
- buildHit.entry,
731
+ buildDocument,
627
732
  descriptor,
628
733
  ),
629
734
  );
630
735
  return { kind: "serve", response };
631
736
  }
632
737
 
633
- // MISS (no entry, invalid reactVersion, or store read failure): axis 1
634
- // + a background capture scheduled once the response is known servable.
635
- return { kind: "miss", descriptor, ssrModule };
738
+ return miss;
636
739
  }
637
740
 
638
741
  /**
@@ -652,10 +755,7 @@ function* preparePayloadPlan<TEnv>(
652
755
  );
653
756
  const result = replay.result;
654
757
  const pprReplayStatus = replay.status;
655
- const partialCaptureNeeded =
656
- "captureNeeded" in replay && replay.captureNeeded === true;
657
- const partialCaptureKey =
658
- "captureKey" in replay ? replay.captureKey : undefined;
758
+ const healKey = "healKey" in replay ? replay.healKey : undefined;
659
759
 
660
760
  if (!result) {
661
761
  // Fall back to full render
@@ -684,10 +784,9 @@ function* preparePayloadPlan<TEnv>(
684
784
  return {
685
785
  kind: "payload",
686
786
  payload: buildFullPayload(match, ctx, url, reqCtx, handleStore),
687
- hasInterceptSlots: false,
787
+ sourceScoped: false,
688
788
  pprReplayStatus,
689
- partialCaptureNeeded,
690
- partialCaptureKey,
789
+ healKey,
691
790
  };
692
791
  }
693
792
 
@@ -702,15 +801,12 @@ function* preparePayloadPlan<TEnv>(
702
801
  // intercepted server-side (X-RSC-Reload) and never delivers a
703
802
  // different-router payload to the client.
704
803
  routerId: ctx.router.id,
705
- segments: gateTransitions(
706
- result.segments,
707
- reqCtx,
708
- ctx.router.onError,
709
- ),
804
+ segments: attachTransitionWhen(result.segments, reqCtx),
710
805
  matched: result.matched,
711
806
  diff: result.diff,
712
807
  resolvedIds: result.resolvedIds,
713
808
  params: result.params,
809
+ routeName: publicRouteName(result.routeName),
714
810
  isPartial: true,
715
811
  slots: result.slots,
716
812
  interceptTargets: result.interceptTargets,
@@ -723,10 +819,9 @@ function* preparePayloadPlan<TEnv>(
723
819
  stateCookieName: ctx.router.resolvedStateCookieName,
724
820
  },
725
821
  },
726
- hasInterceptSlots: !!result.slots,
822
+ sourceScoped: result.interceptTargeted === true,
727
823
  pprReplayStatus,
728
- partialCaptureNeeded,
729
- partialCaptureKey,
824
+ healKey,
730
825
  };
731
826
  }
732
827
 
@@ -745,14 +840,10 @@ function* preparePayloadPlan<TEnv>(
745
840
  };
746
841
  }
747
842
 
748
- // Caching is now handled in router.match() via cache provider in request context
749
- // match.segments already contains cached or fresh segments as appropriate
750
-
751
843
  return {
752
844
  kind: "payload",
753
845
  payload: buildFullPayload(match, ctx, url, reqCtx, handleStore),
754
- hasInterceptSlots: false,
755
- partialCaptureNeeded: false,
846
+ sourceScoped: false,
756
847
  };
757
848
  }
758
849
 
@@ -782,53 +873,227 @@ async function matchPartialAndRecordParams<TEnv>(
782
873
  return replay;
783
874
  }
784
875
 
876
+ /**
877
+ * A document serve's store read: a getShell entry, or a prelude-first read
878
+ * (readShellDocument) whose entry is the head and whose snapshot arrives on
879
+ * its own promise.
880
+ */
881
+ type ShellStoreRead =
882
+ | { entry: ShellCacheEntry; shouldRevalidate?: boolean; read?: undefined }
883
+ | {
884
+ entry: ShellEntryHead;
885
+ shouldRevalidate?: boolean;
886
+ read: ShellDocumentRead;
887
+ };
888
+
785
889
  /**
786
890
  * Shell store read that degrades to null on failure (axis 1 MISS, never a 500)
787
891
  * and records the raw `ppr:shell-read` outcome (pre-validity-gates, so a
788
892
  * version-mismatch lifecycle miss stays distinguishable from a store miss).
893
+ * A store with a prelude-first read (readShellDocument) resolves before the
894
+ * snapshot is read, so the HIT commits without waiting for it.
789
895
  */
790
- async function readShellEntry<
791
- TStore extends { getShell(key: string): Promise<unknown> },
792
- >(
793
- store: TStore,
896
+ async function readShellEntry(
897
+ store: SegmentCacheStore & {
898
+ getShell: NonNullable<SegmentCacheStore["getShell"]>;
899
+ },
794
900
  key: string,
795
901
  reqCtx: ReturnType<typeof getRequestContext>,
796
- ): Promise<Awaited<ReturnType<TStore["getShell"]>> | null> {
797
- let cached: Awaited<ReturnType<TStore["getShell"]>> | null = null;
902
+ tagHints: readonly string[] | undefined,
903
+ ): Promise<ShellStoreRead | null> {
904
+ let cached: ShellStoreRead | null = null;
798
905
  const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
799
906
  try {
800
- // The constraint types the call as Promise<unknown>; the instantiated
801
- // TStore carries the real entry type, so the assertion restores it.
802
- cached = (await store.getShell(key)) as Awaited<
803
- ReturnType<TStore["getShell"]>
804
- >;
907
+ if (store.readShellDocument) {
908
+ const read = await store.readShellDocument(
909
+ key,
910
+ tagHints ? { tagHints } : undefined,
911
+ );
912
+ cached = read
913
+ ? { entry: read.entry, shouldRevalidate: read.shouldRevalidate, read }
914
+ : null;
915
+ } else {
916
+ cached = await store.getShell(key);
917
+ }
805
918
  } catch (error) {
806
919
  reportCacheError(error, "cache-read", "[ShellServe] getShell");
807
920
  }
808
921
  if (reqCtx._metricsStore) {
922
+ const stats = cached?.read?.stats;
809
923
  appendMetric(
810
924
  reqCtx._metricsStore,
811
925
  "ppr:shell-read",
812
926
  shellReadStart,
813
927
  performance.now() - shellReadStart,
814
928
  undefined,
815
- cached ? "hit" : "miss",
929
+ cached ? (stats ? `hit ${stats.tier}` : "hit") : "miss",
816
930
  );
931
+ if (stats)
932
+ recordShellReadStats(reqCtx._metricsStore, shellReadStart, stats);
817
933
  }
818
934
  return cached;
819
935
  }
820
936
 
821
937
  /**
822
- * Dev Server-Timing mirror (issue #651): a capture completes AFTER its
823
- * triggering response committed, so its outcome can only ride a LATER
824
- * response's header. Read-and-clear keeps one capture = one report;
825
- * dev-only (see takeCaptureDebugEventForTiming).
938
+ * A prelude-first read's sub-steps as `debugPerformance` rows under
939
+ * `ppr:shell-read`. The marker row starts with the prelude row because the
940
+ * two run in parallel; its desc says how long the read still waited on the
941
+ * marker once the prelude was in. On a deployed worker the clock only
942
+ * advances on I/O, so the byte counts are the cost signal for the CPU parts.
943
+ */
944
+ function recordShellReadStats(
945
+ store: MetricsStore,
946
+ readStart: number,
947
+ stats: ShellReadStats,
948
+ ): void {
949
+ let at = readStart;
950
+ const row = (label: string, ms: number | undefined, desc: string): void => {
951
+ if (ms !== undefined) appendMetric(store, label, at, ms, 1, desc);
952
+ };
953
+ if (stats.memo) {
954
+ row(
955
+ "ppr:shell-memo",
956
+ 0,
957
+ `${stats.memo.hit ? "hit" : "miss"} size=${stats.memo.bytes}b`,
958
+ );
959
+ }
960
+ // A KV hit after an L1 miss: the L1 attempt first, then the KV read.
961
+ row("ppr:shell-l1-miss", stats.l1MissMs, stats.l1MissReason ?? "miss");
962
+ at += stats.l1MissMs ?? 0;
963
+ row("ppr:shell-match", stats.matchMs, stats.tier);
964
+ at += stats.matchMs ?? 0;
965
+ row("ppr:shell-head", stats.headMs, `bytes=${stats.headBytes ?? 0}`);
966
+ at += stats.headMs ?? 0;
967
+ row("ppr:shell-prelude", stats.preludeMs, `bytes=${stats.preludeBytes ?? 0}`);
968
+ let marker = `tags=${stats.tags ?? 0} ${stats.markerSerial ? "serial" : "parallel"} commit-wait=${(stats.markerWaitMs ?? 0).toFixed(2)}ms`;
969
+ if (stats.markerMemo) marker += ` memo=${stats.markerMemo}`;
970
+ if (stats.markerHinted) {
971
+ marker += ` hint=${stats.markerHintHits ?? 0}/${stats.markerHinted.length}`;
972
+ if (stats.markerLeadMs !== undefined) {
973
+ marker += ` lead=${stats.markerLeadMs.toFixed(2)}ms`;
974
+ }
975
+ }
976
+ if (stats.freshReads) marker += " fresh-reads";
977
+ row("ppr:shell-marker", stats.markerMs, marker);
978
+ }
979
+
980
+ /**
981
+ * openShellDocument (the HIT's integrity gate and only prelude decode) as a
982
+ * `ppr:shell-open` row: CPU only, so it reads 0 on a deployed worker.
983
+ */
984
+ function openShellDocumentMetered(
985
+ reqCtx: RequestContext<any>,
986
+ stored: ShellStoreRead,
987
+ ): ShellDocument | null {
988
+ const read = stored.read;
989
+ const open = (): ShellDocument | null =>
990
+ stored.read
991
+ ? openShellDocument(stored.entry, stored.read)
992
+ : openShellDocument(stored.entry);
993
+ const store = reqCtx._metricsStore;
994
+ if (!store) return open();
995
+ const start = performance.now();
996
+ const document = open();
997
+ appendMetric(
998
+ store,
999
+ "ppr:shell-open",
1000
+ start,
1001
+ performance.now() - start,
1002
+ undefined,
1003
+ `cpu ${read ? "raw" : "base64-decode"} prelude=${document?.prelude.length ?? 0}b`,
1004
+ );
1005
+ return document;
1006
+ }
1007
+
1008
+ /**
1009
+ * The request's per-request CSP nonce. BOTH sources count: the
1010
+ * createRouter({ nonce }) provider (`nonce`) and a middleware
1011
+ * ctx.set(nonce, …) token write; the provider-only check missed the latter
1012
+ * (issue #656). Read at the commit point, after the whole middleware chain.
1013
+ */
1014
+ function activeRequestNonce(
1015
+ nonce: string | undefined,
1016
+ reqCtx: RequestContext<any>,
1017
+ ): string | undefined {
1018
+ return nonce ?? contextGet(reqCtx._variables, nonceToken);
1019
+ }
1020
+
1021
+ /**
1022
+ * The shell key a request reads and captures: `baseKey`, partitioned by the
1023
+ * route's request partition (cache-scope.ts resolveShellPartition; a
1024
+ * partition equal to the default key leaves it unchanged). `baseKey` itself,
1025
+ * synchronously and with no work, when nothing partitions the route; else a
1026
+ * promise that resolves undefined when the key failed to resolve (reported
1027
+ * as `[source] shell key`): such a request reads and captures no shell,
1028
+ * never another partition's.
1029
+ */
1030
+ function resolveRequestShellKey(
1031
+ baseKey: string,
1032
+ route: RouteSnapshot<any> | null,
1033
+ store: SegmentCacheStore,
1034
+ reqCtx: RequestContext<any>,
1035
+ source: string,
1036
+ ): string | Promise<string | undefined> {
1037
+ const partition = resolveShellPartition(
1038
+ route?.cacheScope,
1039
+ store,
1040
+ reqCtx.url.pathname,
1041
+ route?.params,
1042
+ );
1043
+ if (!partition) return baseKey;
1044
+ return partition.then(
1045
+ (resolved) =>
1046
+ resolved === null ? baseKey : partitionShellKey(baseKey, resolved),
1047
+ (error: unknown) => {
1048
+ reportCacheError(error, "cache-read", `[${source}] shell key`);
1049
+ return undefined;
1050
+ },
1051
+ );
1052
+ }
1053
+
1054
+ /**
1055
+ * Observe the explicit-scope composition in withCacheLookup: the route's own
1056
+ * cache() scope served the request (`explicit-cache-hit`) or refused it at
1057
+ * lookup time (`cache-disabled`). The two are exclusive per lookup, and
1058
+ * neither fires when the seeded doc record served.
1059
+ */
1060
+ function observeExplicitScope(): {
1061
+ onExplicitHit: () => void;
1062
+ onExplicitBypass: () => void;
1063
+ bypassed: () => boolean;
1064
+ status: (otherwise: PprReplayStatus) => PprReplayStatus;
1065
+ } {
1066
+ let observed: "hit" | "bypass" | undefined;
1067
+ return {
1068
+ onExplicitHit: () => {
1069
+ observed = "hit";
1070
+ },
1071
+ onExplicitBypass: () => {
1072
+ observed = "bypass";
1073
+ },
1074
+ bypassed: () => observed === "bypass",
1075
+ status: (otherwise) =>
1076
+ observed === "hit"
1077
+ ? { outcome: "BYPASS", reason: "explicit-cache-hit" }
1078
+ : observed === "bypass"
1079
+ ? { outcome: "BYPASS", reason: "cache-disabled" }
1080
+ : otherwise,
1081
+ };
1082
+ }
1083
+
1084
+ /**
1085
+ * Server-Timing mirror (issue #651): a capture or a HIT tail completes AFTER
1086
+ * its triggering response committed, so its numbers can only ride a LATER
1087
+ * response's header. Read-and-clear keeps one report per run. The capture
1088
+ * half is dev-only (see takeCaptureDebugEventForTiming); the tail half runs
1089
+ * wherever a tail was buffered (serveShellHit).
826
1090
  */
827
- function mirrorPprServerTimingsForDev(
1091
+ function mirrorPprServerTimings(
828
1092
  key: string,
829
1093
  reqCtx: ReturnType<typeof getRequestContext>,
830
1094
  ): void {
831
- if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
1095
+ if (!reqCtx._metricsStore) return;
1096
+ if (process.env.NODE_ENV !== "production") {
832
1097
  const lastCapture = takeCaptureDebugEventForTiming(key);
833
1098
  if (lastCapture) {
834
1099
  appendMetric(
@@ -841,22 +1106,23 @@ function mirrorPprServerTimingsForDev(
841
1106
  describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
842
1107
  );
843
1108
  }
844
- // Same mirror for the previous HIT's tail: its per-stage numbers
845
- // (seed/match/handover/first-html/complete) finished after that
846
- // response's headers were committed, so they ride THIS request's
847
- // Server-Timing as `ppr:tail;dur=<complete ms>`.
848
- const lastTail = takeShellTailTimingForServerTiming(key);
849
- if (lastTail) {
850
- appendMetric(
851
- reqCtx._metricsStore,
852
- "ppr:tail",
853
- performance.now(),
854
- lastTail.completeMs ?? 0,
855
- undefined,
856
- // completeMs already rides as this entry's dur — drop it from desc.
857
- describeShellTailTiming({ ...lastTail, completeMs: undefined }),
858
- );
859
- }
1109
+ }
1110
+ // Same mirror for the previous HIT's tail: its per-stage numbers
1111
+ // (snapshot/seed/match/handover/first-html/complete) finished after that
1112
+ // response's headers were committed, so they ride THIS request's
1113
+ // Server-Timing as `ppr:tail;dur=<complete ms>`. In production a tail is
1114
+ // buffered only when its own HIT collected metrics (serveShellHit).
1115
+ const lastTail = takeShellTailTimingForServerTiming(key);
1116
+ if (lastTail) {
1117
+ appendMetric(
1118
+ reqCtx._metricsStore,
1119
+ "ppr:tail",
1120
+ performance.now(),
1121
+ lastTail.completeMs ?? 0,
1122
+ undefined,
1123
+ // completeMs already rides as this entry's dur — drop it from desc.
1124
+ describeShellTailTiming({ ...lastTail, completeMs: undefined }),
1125
+ );
860
1126
  }
861
1127
  }
862
1128
 
@@ -867,7 +1133,7 @@ function renderPreparedRscResponse<TEnv>(
867
1133
  ): Promise<Response> {
868
1134
  const { ctx, request, env, url, isPartial, nonce, reqCtx, renderSpan } =
869
1135
  input;
870
- const { payload, pprReplayStatus, hasInterceptSlots } = prepared;
1136
+ const { payload, pprReplayStatus, sourceScoped } = prepared;
871
1137
  const metricsStore = reqCtx._metricsStore;
872
1138
 
873
1139
  const rscHeaders: Record<string, string> = {
@@ -884,18 +1150,20 @@ function renderPreparedRscResponse<TEnv>(
884
1150
  serializePprReplayStatus(pprReplayStatus);
885
1151
  }
886
1152
  // Tell the client's prefetch cache to scope this response to its source
887
- // URL (instead of the default source-agnostic wildcard). Intercept
888
- // responses depend on the source page matching an intercept rule, so
889
- // they must not be reused for navigations from other sources.
890
- if (hasInterceptSlots) {
1153
+ // URL (instead of the default source-agnostic wildcard). A route an
1154
+ // intercept targets renders the modal or the full page depending on the
1155
+ // source, so neither response may be reused from another source: a full
1156
+ // page prefetched where the intercept does not apply would otherwise
1157
+ // serve a later click where it does (#1007).
1158
+ if (sourceScoped) {
891
1159
  rscHeaders["x-rsc-prefetch-scope"] = "source";
892
1160
  }
893
1161
  // Enable browser HTTP caching for prefetch responses only.
894
1162
  // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
895
- // non-intercept context (intercept responses depend on source page),
896
- // and a configured cache-control value (false disables caching).
897
- const isPrefetch = request.headers.has("X-Rango-Prefetch");
898
- if (isPrefetch && isPartial && !hasInterceptSlots) {
1163
+ // a source-agnostic response (see sourceScoped above), and a configured
1164
+ // cache-control value (false disables caching).
1165
+ const isPrefetch = requestHeaders(request).has("X-Rango-Prefetch");
1166
+ if (isPrefetch && isPartial && !sourceScoped) {
899
1167
  const cc = ctx.router.prefetchCacheControl;
900
1168
  if (cc) {
901
1169
  rscHeaders["cache-control"] = cc;
@@ -944,7 +1212,7 @@ function renderPreparedRscResponse<TEnv>(
944
1212
  */
945
1213
  function scheduleNavigationShellCapture<TEnv>(
946
1214
  input: RequestRenderInput<TEnv>,
947
- partialCaptureKey: string | undefined,
1215
+ captureKey: string,
948
1216
  ): void {
949
1217
  const { ctx, request, env, url, reqCtx } = input;
950
1218
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
@@ -967,8 +1235,7 @@ function scheduleNavigationShellCapture<TEnv>(
967
1235
  },
968
1236
  createShellCaptureDescriptor(
969
1237
  ctx,
970
- partialCaptureKey ??
971
- buildNavigationShellKey(url, reqCtx._searchParamsFilter),
1238
+ captureKey,
972
1239
  shellSearchSeed(url, reqCtx._searchParamsFilter),
973
1240
  url.origin,
974
1241
  pprConfig,
@@ -981,10 +1248,11 @@ function scheduleNavigationShellCapture<TEnv>(
981
1248
  /**
982
1249
  * Reuse a PPR capture's canonical segment record for a partial navigation.
983
1250
  * The ordinary matchPartial pipeline remains authoritative: it projects the
984
- * cached target tree against the client's segment ids, evaluates revalidation,
985
- * and resolves every loader fresh. Only segment-family records are seeded;
986
- * captured item/response/loader values belong to document parity and must not
987
- * pin navigation data.
1251
+ * cached target tree against the client's segment ids and evaluates
1252
+ * revalidation. Only the segment family is seeded into the store overlay, so
1253
+ * item and response reads stay live; a record that served also arms the
1254
+ * bake-lane loader pins (onHit), which serve as on a document HIT, while live
1255
+ * loaders run fresh.
988
1256
  */
989
1257
  async function matchPartialWithPprReplay<TEnv>(
990
1258
  ctx: HandlerContext<TEnv>,
@@ -1041,7 +1309,7 @@ async function matchPartialWithPprReplay<TEnv>(
1041
1309
  return { result, status: finalStatus };
1042
1310
  };
1043
1311
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
1044
- const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
1312
+ const activeNonce = activeRequestNonce(nonce, reqCtx);
1045
1313
  const store = reqCtx._cacheStore;
1046
1314
 
1047
1315
  if (!pprConfig) return runMatch();
@@ -1077,8 +1345,8 @@ async function matchPartialWithPprReplay<TEnv>(
1077
1345
  // one-shot corruption recovery request still replay the same segment record,
1078
1346
  // but through the server decode path.
1079
1347
  armFragments =
1080
- request.headers.get(SEGMENT_FRAGMENT_RECOVERY_HEADER) !== "1" &&
1081
- request.headers.get(SEGMENT_FRAGMENT_CAPABILITY_HEADER) === "1";
1348
+ requestHeaders(request).get(SEGMENT_FRAGMENT_RECOVERY_HEADER) !== "1" &&
1349
+ requestHeaders(request).get(SEGMENT_FRAGMENT_CAPABILITY_HEADER) === "1";
1082
1350
  // The prerender probe and the cache-opt-out gate below both need the
1083
1351
  // classified snapshot; resolve (and persist) it once.
1084
1352
  const routeSnapshot = classifiedRouteSnapshot(reqCtx);
@@ -1127,8 +1395,19 @@ async function matchPartialWithPprReplay<TEnv>(
1127
1395
  return runMatch({ outcome: "BYPASS", reason: "cache-disabled" });
1128
1396
  }
1129
1397
 
1130
- const key = buildShellKey(url, reqCtx._searchParamsFilter);
1131
- const navigationKey = `${key}:navigation`;
1398
+ // The visitor's own partition (resolveShellPartition): replay never
1399
+ // crosses into another partition's shell.
1400
+ const key = await resolveRequestShellKey(
1401
+ buildShellKey(url, reqCtx._searchParamsFilter),
1402
+ routeSnapshot,
1403
+ store,
1404
+ reqCtx,
1405
+ "NavigationPPR",
1406
+ );
1407
+ if (key === undefined) {
1408
+ return runMatch({ outcome: "BYPASS", reason: "read-error" });
1409
+ }
1410
+ const navigationKey = navigationShellKey(key);
1132
1411
  let cached: Awaited<ReturnType<typeof store.getShell>> = null;
1133
1412
  try {
1134
1413
  cached = await store.getShell(key, { claimRevalidation: false });
@@ -1152,11 +1431,7 @@ async function matchPartialWithPprReplay<TEnv>(
1152
1431
  }
1153
1432
  }
1154
1433
 
1155
- if (
1156
- !snapshot &&
1157
- bypassReason !== "handler-live-holes" &&
1158
- bypassReason !== "transition-when"
1159
- ) {
1434
+ if (!snapshot) {
1160
1435
  let navigationCached: Awaited<ReturnType<typeof store.getShell>> = null;
1161
1436
  try {
1162
1437
  navigationCached = await store.getShell(navigationKey, {
@@ -1181,32 +1456,6 @@ async function matchPartialWithPprReplay<TEnv>(
1181
1456
  }
1182
1457
  }
1183
1458
 
1184
- if (!snapshot) {
1185
- // Production build manifests are local module data. In dev, resolving a
1186
- // missing build shell would foreground-fetch /__rsc_shell and block an
1187
- // otherwise ordinary navigation on capture, so replay remains runtime-only.
1188
- const buildHit = await lookupBuildShell(
1189
- url,
1190
- ctx.version,
1191
- store,
1192
- undefined,
1193
- reqCtx._searchParamsFilter,
1194
- );
1195
- if (buildHit?.stale) {
1196
- bypassReason ??= "stale-build-entry";
1197
- } else if (buildHit) {
1198
- const buildDecision = replayableShellSnapshot(
1199
- buildHit.entry,
1200
- ctx.version,
1201
- );
1202
- if ("snapshot" in buildDecision) {
1203
- snapshot = buildDecision.snapshot;
1204
- } else {
1205
- bypassReason ??= buildDecision.reason;
1206
- }
1207
- }
1208
- }
1209
-
1210
1459
  if (!snapshot) {
1211
1460
  // Report-only marker for routes with an ENABLED route-derived scope: the
1212
1461
  // lookup's own outcome must stay reportable even without a replayable
@@ -1221,25 +1470,20 @@ async function matchPartialWithPprReplay<TEnv>(
1221
1470
  // implicit scope over the REAL doc: partition in
1222
1471
  // resolveShellImplicitCacheScope).
1223
1472
  if (routeCacheScope?.enabled) {
1224
- let explicitCacheHit = false;
1225
- let explicitCacheBypass = false;
1473
+ const explicit = observeExplicitScope();
1226
1474
  const previousImplicitCache = reqCtx._shellImplicitCache;
1227
1475
  reqCtx._shellImplicitCache = {
1228
- onExplicitHit: () => {
1229
- explicitCacheHit = true;
1230
- },
1231
- onExplicitBypass: () => {
1232
- explicitCacheBypass = true;
1233
- },
1476
+ onExplicitHit: explicit.onExplicitHit,
1477
+ onExplicitBypass: explicit.onExplicitBypass,
1234
1478
  };
1235
1479
  try {
1236
1480
  const result = await matchPartialForReplay();
1237
- const provisionalStatus: PprReplayStatus = explicitCacheBypass
1238
- ? { outcome: "BYPASS", reason: "cache-disabled" }
1239
- : explicitCacheHit
1240
- ? { outcome: "BYPASS", reason: "explicit-cache-hit" }
1241
- : { outcome: "BYPASS", reason: bypassReason ?? "no-entry" };
1242
- const status = finalizeReplayStatus(provisionalStatus);
1481
+ const status = finalizeReplayStatus(
1482
+ explicit.status({
1483
+ outcome: "BYPASS",
1484
+ reason: bypassReason ?? "no-entry",
1485
+ }),
1486
+ );
1243
1487
  return {
1244
1488
  result,
1245
1489
  status,
@@ -1253,12 +1497,14 @@ async function matchPartialWithPprReplay<TEnv>(
1253
1497
  // capture derives from THIS request's context, so its doc record
1254
1498
  // records. Excluding it left replay dead until the document
1255
1499
  // recaptured. The always-false route stays protected: its every
1256
- // lookup refuses, so explicitCacheBypass suppresses the heal.
1257
- captureNeeded:
1258
- !explicitCacheBypass &&
1500
+ // lookup refuses, so its bypass suppresses the heal.
1501
+ healKey:
1502
+ !explicit.bypassed() &&
1259
1503
  reqCtx._pprReplayPostMatchReason === undefined &&
1260
1504
  (shouldHealReplayMiss(bypassReason) ||
1261
- bypassReason === "no-segment-snapshot"),
1505
+ bypassReason === "no-segment-snapshot")
1506
+ ? navigationKey
1507
+ : undefined,
1262
1508
  };
1263
1509
  } finally {
1264
1510
  reqCtx._shellImplicitCache = previousImplicitCache;
@@ -1270,26 +1516,37 @@ async function matchPartialWithPprReplay<TEnv>(
1270
1516
  });
1271
1517
  return {
1272
1518
  ...match,
1273
- captureNeeded:
1519
+ healKey:
1274
1520
  reqCtx._pprReplayPostMatchReason === undefined &&
1275
- shouldHealReplayMiss(bypassReason),
1521
+ shouldHealReplayMiss(bypassReason)
1522
+ ? navigationKey
1523
+ : undefined,
1276
1524
  };
1277
1525
  }
1278
1526
 
1279
1527
  const previousImplicitCache = reqCtx._shellImplicitCache;
1528
+ const previousLoaderSeed = reqCtx._shellLoaderSeed;
1529
+ let loaderSeed: ReturnType<typeof buildShellLoaderSeed> | undefined;
1280
1530
  let segmentReplayHit = false;
1281
1531
  let segmentReplayCorrupt = false;
1282
- let explicitCacheHit = false;
1283
- let explicitCacheBypass = false;
1532
+ const explicit = observeExplicitScope();
1284
1533
  reqCtx._shellImplicitCache = {
1285
1534
  ttl: pprConfig.ttl,
1286
1535
  swr: pprConfig.swr,
1287
- store: new SeededShellStore(store, snapshot, {
1288
- segmentsOnly: true,
1289
- }),
1536
+ store: new SeededShellStore(store, snapshot),
1290
1537
  keyPrefix: "doc",
1291
- onHit: () => {
1538
+ // Arms the bake-lane loader pins a document HIT serves (loader-cache.ts
1539
+ // resolveLoaderData), decoded only now: the lookup awaits this before
1540
+ // the route's loaders resolve, and an explicit-tier hit or a miss runs
1541
+ // the loaders fresh and never reads a pin.
1542
+ onHit: async () => {
1292
1543
  segmentReplayHit = true;
1544
+ // Never rejects: the lookup awaits this inside its own try, and an
1545
+ // unreadable seed leaves those loaders to run fresh.
1546
+ const seed = await (loaderSeed ??= buildShellLoaderSeed(snapshot).catch(
1547
+ () => undefined,
1548
+ ));
1549
+ if (seed) reqCtx._shellLoaderSeed = seed;
1293
1550
  },
1294
1551
  onCorrupt: () => {
1295
1552
  segmentReplayCorrupt = true;
@@ -1297,27 +1554,18 @@ async function matchPartialWithPprReplay<TEnv>(
1297
1554
  // Arms the explicit-scope composition in withCacheLookup: a route-derived
1298
1555
  // cache() scope stays authoritative, the seeded doc record supplies the
1299
1556
  // match only on its miss. An explicit-tier hit must NOT report a replay
1300
- // HIT — it served under the consumer's own semantics.
1301
- onExplicitHit: () => {
1302
- explicitCacheHit = true;
1303
- },
1304
- // Lookup-time condition() refusal (or a scope with no store): the gate
1305
- // deliberately does not pre-decide predicates, so the truthful
1306
- // cache-disabled report comes from the lookup that actually refused.
1307
- onExplicitBypass: () => {
1308
- explicitCacheBypass = true;
1309
- },
1557
+ // HIT — it served under the consumer's own semantics. A lookup-time
1558
+ // condition() refusal (or a scope with no store) reports cache-disabled:
1559
+ // the gate deliberately does not pre-decide predicates.
1560
+ onExplicitHit: explicit.onExplicitHit,
1561
+ onExplicitBypass: explicit.onExplicitBypass,
1310
1562
  };
1311
1563
 
1312
1564
  try {
1313
1565
  const result = await matchPartialForReplay();
1314
1566
  const provisionalStatus: PprReplayStatus = segmentReplayHit
1315
1567
  ? { outcome: "HIT", freshness }
1316
- : explicitCacheHit
1317
- ? { outcome: "BYPASS", reason: "explicit-cache-hit" }
1318
- : explicitCacheBypass
1319
- ? { outcome: "BYPASS", reason: "cache-disabled" }
1320
- : { outcome: "BYPASS", reason: "snapshot-miss" };
1568
+ : explicit.status({ outcome: "BYPASS", reason: "snapshot-miss" });
1321
1569
  // A prerender serve or intercept resolution leaves the seeded record
1322
1570
  // unconsulted (prerender short-circuits before the scope; intercepts
1323
1571
  // keep snapshot.cacheScope) — the "snapshot-miss" guess would blame the
@@ -1326,17 +1574,19 @@ async function matchPartialWithPprReplay<TEnv>(
1326
1574
  return {
1327
1575
  result,
1328
1576
  status,
1329
- captureNeeded:
1330
- segmentReplayCorrupt && reqCtx._pprReplayPostMatchReason === undefined,
1331
1577
  // A corrupt document snapshot must be overwritten at the document key;
1332
1578
  // writing only the secondary navigation key would leave the preferred
1333
1579
  // document entry shadowing the repair forever. The capture remains marked
1334
1580
  // navigationOnly, so a document request ignores it and recaptures a
1335
1581
  // document-safe shell under the same key.
1336
- captureKey: segmentReplayCorrupt ? snapshotStoreKey : undefined,
1582
+ healKey:
1583
+ segmentReplayCorrupt && reqCtx._pprReplayPostMatchReason === undefined
1584
+ ? snapshotStoreKey
1585
+ : undefined,
1337
1586
  };
1338
1587
  } finally {
1339
1588
  reqCtx._shellImplicitCache = previousImplicitCache;
1589
+ reqCtx._shellLoaderSeed = previousLoaderSeed;
1340
1590
  }
1341
1591
  }
1342
1592
 
@@ -1381,6 +1631,76 @@ export function resolveShellHitRedirectTarget(
1381
1631
  );
1382
1632
  }
1383
1633
 
1634
+ /**
1635
+ * Buffer a finished tail timing for the next request's Server-Timing, and
1636
+ * under debugPerformance print it for this request (whose own Server-Timing
1637
+ * was sent at the commit, before the tail ran).
1638
+ */
1639
+ function publishTailTiming(
1640
+ tailTiming: ShellTailTiming | null,
1641
+ metricsStore: MetricsStore | undefined,
1642
+ request: Request,
1643
+ url: URL,
1644
+ ): void {
1645
+ if (!tailTiming) return;
1646
+ publishShellTailTiming(tailTiming);
1647
+ if (metricsStore) {
1648
+ console.log(
1649
+ `[RSC Perf] ${request.method} ${url.pathname} shell tail: ${describeShellTailTiming(tailTiming)}`,
1650
+ );
1651
+ }
1652
+ }
1653
+
1654
+ const PENDING = Symbol("pending");
1655
+
1656
+ /**
1657
+ * Whether `value` is not a promise, or a promise that has already settled:
1658
+ * racing it against an already-resolved promise, a settled one's reaction is
1659
+ * queued first and wins.
1660
+ */
1661
+ async function isSettled(value: unknown): Promise<boolean> {
1662
+ if (!(value instanceof Promise)) return true;
1663
+ return Promise.race([value, PENDING]).then(
1664
+ (first) => first !== PENDING,
1665
+ () => true,
1666
+ );
1667
+ }
1668
+
1669
+ /**
1670
+ * Evict a shell whose doc record cannot be replayed. The store has no shell
1671
+ * delete, so the entry is overwritten with a tombstone: a navigationOnly
1672
+ * entry with no document half and no snapshot, which document serving
1673
+ * treats as a MISS (shellServePlan, the build shell included) and partial
1674
+ * replay as `no-segment-snapshot`. The recapture overwrites it with a sound
1675
+ * entry. Only for a broken entry: a snapshot read that was merely slow
1676
+ * (`snapshotFailure: "unavailable"`) leaves the entry alone.
1677
+ */
1678
+ async function degradeUnreplayableShell(
1679
+ descriptor: ShellCaptureDescriptor,
1680
+ entry: ShellEntryHead,
1681
+ scheduleRecapture: () => void,
1682
+ ): Promise<void> {
1683
+ const store = descriptor.store;
1684
+ try {
1685
+ await store?.putShell?.(
1686
+ descriptor.key,
1687
+ {
1688
+ reactVersion: entry.reactVersion,
1689
+ buildVersion: entry.buildVersion,
1690
+ snapshot: [],
1691
+ navigationOnly: true,
1692
+ createdAt: Date.now(),
1693
+ },
1694
+ descriptor.ttl,
1695
+ 0,
1696
+ );
1697
+ } catch (error) {
1698
+ reportCacheError(error, "cache-write", "[ShellServe] tombstone");
1699
+ }
1700
+ store?.dropShellMemo?.(descriptor.key);
1701
+ scheduleRecapture();
1702
+ }
1703
+
1384
1704
  /**
1385
1705
  * Serve a validated shell HIT: commit the stored prelude bytes NOW and run the
1386
1706
  * live tail behind them inside the response stream. Plain byte concatenation is
@@ -1398,15 +1718,17 @@ function serveShellHit(
1398
1718
  reqCtx: RequestContext<any>,
1399
1719
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
1400
1720
  ssrModule: SSRModule,
1401
- entry: DocumentShellCacheEntry,
1721
+ document: ShellDocument,
1402
1722
  descriptor: ShellCaptureDescriptor,
1403
1723
  ): Response {
1404
- const preludeBytes = base64ToBytes(entry.prelude);
1405
- // Per-stage tail timing for the dev `ppr:tail` Server-Timing mirror. Dev
1406
- // only (NODE_ENV folds the branch away in production builds); offsets are
1407
- // relative to this commit point.
1724
+ const { entry, prelude: preludeBytes } = document;
1725
+ const metricsStore = reqCtx._metricsStore;
1726
+ // Per-stage tail timing for the `ppr:tail` Server-Timing mirror and the
1727
+ // perf console line: always in dev, in production only when this request
1728
+ // collects debugPerformance metrics. Offsets are relative to this commit
1729
+ // point.
1408
1730
  const tailTiming: ShellTailTiming | null =
1409
- process.env.NODE_ENV !== "production"
1731
+ process.env.NODE_ENV !== "production" || metricsStore
1410
1732
  ? {
1411
1733
  key: descriptor.key,
1412
1734
  outcome: "complete",
@@ -1414,6 +1736,8 @@ function serveShellHit(
1414
1736
  }
1415
1737
  : null;
1416
1738
  const tailT0 = tailTiming ? performance.now() : 0;
1739
+ const scheduleRecapture = (): void =>
1740
+ scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
1417
1741
 
1418
1742
  const createTailContext = (): RequestContext<any> => {
1419
1743
  const tailCtx: RequestContext<any> = Object.create(reqCtx);
@@ -1486,7 +1810,7 @@ function serveShellHit(
1486
1810
  }
1487
1811
  return observePhase(PHASES.ssr, () =>
1488
1812
  ssrModule.resumeShellHTML!(rscStream, {
1489
- postponed: entry.postponed,
1813
+ postponed: document.postponed,
1490
1814
  nonce: undefined,
1491
1815
  // The shell key's own search — identical to the capture seed for
1492
1816
  // this key, so the resume tree matches the captured tree.
@@ -1505,68 +1829,77 @@ function serveShellHit(
1505
1829
  ReadableStream<Uint8Array> | { redirect: string }
1506
1830
  > = (async () => {
1507
1831
  // Snapshot seeding (docs/design/ppr-shell-resume.md): the tail render must
1508
- // match the frozen prelude, so pinned cache reads replay their capture-time
1509
- // values via a SeededShellStore overlay while unpinned reads (the holes)
1510
- // stay live. The overlay lives on a DERIVED context so the shared reqCtx is
1511
- // untouched.
1512
- if (entry.snapshot && entry.snapshot.length > 0) {
1513
- const seededCtx = createTailContext();
1514
- if (reqCtx._cacheStore) {
1515
- seededCtx._cacheStore = new SeededShellStore(
1516
- reqCtx._cacheStore,
1517
- entry.snapshot,
1518
- );
1519
- }
1520
- // Loader-family records (bake-lane containers, loader-container-bake):
1521
- // decode into a seed Map for the resolveLoaderData overlay, so the
1522
- // payload's baked container bytes match the frozen prelude while the
1523
- // hole-marker paths keep the fresh run's live nested promises.
1524
- const seedStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
1525
- const loaderSeed = await buildShellLoaderSeed(entry.snapshot);
1526
- if (tailTiming) {
1527
- tailTiming.seedMs = Math.round(performance.now() - tailT0);
1528
- }
1529
- if (INTERNAL_RANGO_DEBUG) {
1530
- console.log(
1531
- `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
1532
- );
1533
- }
1534
- if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
1535
- // Shell fast path (serve side): if the capture recorded the implicit doc
1536
- // segment record and the handler layer declared no liveness, the tail's
1537
- // cache lookup HITs the seeded doc entry — the handler layer is REPLAYED,
1538
- // not re-executed (loaders still run fresh). Anything else degrades to
1539
- // the full tail automatically.
1540
- if (!entry.handlerLiveHoles && !entry.transitionWhen) {
1541
- seededCtx._shellImplicitCache = {
1542
- ttl: descriptor.ttl,
1543
- swr: descriptor.swr,
1544
- keyPrefix: "doc",
1545
- };
1546
- if (INTERNAL_RANGO_DEBUG) {
1547
- console.log(
1548
- `[Server][ppr] shell HIT: fast path armed (implicit doc cache) (abs ${Math.round(performance.now())})`,
1549
- );
1550
- }
1551
- } else if (INTERNAL_RANGO_DEBUG) {
1552
- console.log(
1553
- `[Server][ppr] shell HIT: fast path declined — request-dependent handler/transition state; tail re-runs handlers (abs ${Math.round(performance.now())})`,
1554
- );
1832
+ // match the frozen prelude, so it replays the doc record and serves the
1833
+ // bake-lane loader pins, on a DERIVED context so the shared reqCtx is
1834
+ // untouched. Every cache read the tail makes, a hole's included, goes to
1835
+ // the request's own store.
1836
+ // A prelude-first read delivers the snapshot on its own promise: the
1837
+ // prelude is already committed, so only the tail waits for it.
1838
+ const inMemory = await isSettled(document.snapshot);
1839
+ const snapshot = await document.snapshot;
1840
+ // A snapshot already in memory (shell memo, memory store, build shells)
1841
+ // resolves at once, and the tail's seeding and match would run ahead of
1842
+ // the runtime writing the prelude: one macrotask first. A snapshot still
1843
+ // arriving on I/O has yielded by the time it resolves.
1844
+ if (inMemory) await new Promise<void>((resolve) => setTimeout(resolve, 0));
1845
+ if (tailTiming) {
1846
+ tailTiming.snapshotMs = Math.round(performance.now() - tailT0);
1847
+ if (snapshot)
1848
+ tailTiming.snapshotRecords = countSnapshotFamilies(snapshot);
1849
+ tailTiming.snapshotPruned = entry.prunedRecords;
1850
+ const snapshotStats = document.stats?.snapshot;
1851
+ if (snapshotStats) {
1852
+ tailTiming.snapshotReadMs = Math.round(snapshotStats.readMs);
1853
+ tailTiming.snapshotBytes = snapshotStats.bytes;
1854
+ tailTiming.snapshotParseMs = Math.round(snapshotStats.parseMs);
1555
1855
  }
1556
- // Fragment splice (issue #700): store hits in THIS tail emit their stored
1557
- // segment fragments verbatim (expanded by segment-fragments.ts). The flag
1558
- // lives on the derived context so it can never leak into a capture render,
1559
- // which serializes segments and must see real elements.
1560
- seededCtx._shellFragmentPayload = true;
1561
- return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
1562
1856
  }
1563
- // No snapshot (e.g. a producer B entry): still a shell-HIT tail, so arm the
1564
- // fragment splice on a derived context — never the shared reqCtx, from which
1565
- // scheduleShellCapture derives the capture context (the flag must not be
1566
- // inherited there).
1567
- const fragmentCtx = createTailContext();
1568
- fragmentCtx._shellFragmentPayload = true;
1569
- return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
1857
+ const seededCtx = createTailContext();
1858
+ const records = snapshot ?? [];
1859
+ // Loader-family records (bake-lane containers, loader-container-bake):
1860
+ // decode into a seed Map for the resolveLoaderData overlay, so the
1861
+ // payload's baked container bytes match the frozen prelude while the
1862
+ // hole-marker paths keep the fresh run's live nested promises.
1863
+ const seedStart =
1864
+ INTERNAL_RANGO_DEBUG || tailTiming ? performance.now() : 0;
1865
+ const loaderSeed =
1866
+ records.length > 0 ? await buildShellLoaderSeed(records) : undefined;
1867
+ if (tailTiming) {
1868
+ const seededAt = performance.now();
1869
+ tailTiming.seedMs = Math.round(seededAt - tailT0);
1870
+ tailTiming.seedCpuMs = Math.round(seededAt - seedStart);
1871
+ }
1872
+ if (INTERNAL_RANGO_DEBUG) {
1873
+ console.log(
1874
+ `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
1875
+ );
1876
+ }
1877
+ if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
1878
+ // The handler layer is REPLAYED from the entry's own doc record, never
1879
+ // executed: the implicit doc scope replaces any route-derived scope
1880
+ // (docTail), looks the record up by the key the capture wrote
1881
+ // (fixedDocKey), and reads it from a segment-only overlay of the snapshot
1882
+ // (the real store never holds a doc: record). A lookup that does not hit
1883
+ // throws ShellRecordUnavailableError (withCacheLookup), handled by the
1884
+ // degrade below. Live and promise-carrying bake-lane loaders still run
1885
+ // fresh; a promise-free bake-lane loader is served from its pin
1886
+ // (_shellLoaderSeed, loader-cache.ts). A Prerender route's tail takes
1887
+ // the handler layer from the prerender store before this scope is
1888
+ // consulted.
1889
+ seededCtx._shellImplicitCache = {
1890
+ ttl: descriptor.ttl,
1891
+ swr: descriptor.swr,
1892
+ store: new SeededShellStore(reqCtx._cacheStore!, records),
1893
+ keyPrefix: "doc",
1894
+ docTail: true,
1895
+ fixedDocKey: entry.docKey,
1896
+ };
1897
+ // Fragment splice (issue #700): store hits in THIS tail emit their stored
1898
+ // segment fragments verbatim (expanded by segment-fragments.ts). The flag
1899
+ // lives on the derived context so it can never leak into a capture render,
1900
+ // which serializes segments and must see real elements.
1901
+ seededCtx._shellFragmentPayload = true;
1902
+ return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
1570
1903
  })();
1571
1904
  // The stream below is the only consumer; pre-attach a no-op catch so a tail
1572
1905
  // failure before the stream is pulled never surfaces as an unhandled rejection.
@@ -1575,7 +1908,31 @@ function serveShellHit(
1575
1908
  const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
1576
1909
  const body = new ReadableStream<Uint8Array>({
1577
1910
  async start(controller) {
1578
- controller.enqueue(preludeBytes);
1911
+ // Fixed-size chunks (SHELL_PRELUDE_CHUNK_BYTES): a compressor in front
1912
+ // of the worker emits after each chunk instead of after the whole
1913
+ // prelude. slice() gives every chunk its own buffer.
1914
+ const commitStart = metricsStore ? performance.now() : 0;
1915
+ let chunks = 0;
1916
+ for (
1917
+ let offset = 0;
1918
+ offset < preludeBytes.length;
1919
+ offset += SHELL_PRELUDE_CHUNK_BYTES
1920
+ ) {
1921
+ controller.enqueue(
1922
+ preludeBytes.slice(offset, offset + SHELL_PRELUDE_CHUNK_BYTES),
1923
+ );
1924
+ chunks++;
1925
+ }
1926
+ if (metricsStore) {
1927
+ appendMetric(
1928
+ metricsStore,
1929
+ "ppr:shell-commit",
1930
+ commitStart,
1931
+ performance.now() - commitStart,
1932
+ undefined,
1933
+ `cpu chunks=${chunks} prelude=${preludeBytes.length}b`,
1934
+ );
1935
+ }
1579
1936
  if (INTERNAL_RANGO_DEBUG) {
1580
1937
  console.log(
1581
1938
  `[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
@@ -1647,7 +2004,9 @@ function serveShellHit(
1647
2004
  );
1648
2005
  controller.enqueue(
1649
2006
  new TextEncoder().encode(
1650
- `<script>location.replace(${JSON.stringify(safeTarget)})</script>`,
2007
+ inlineShellScript(
2008
+ `location.replace(${escapeJsonForScript(JSON.stringify(safeTarget))})`,
2009
+ ),
1651
2010
  ),
1652
2011
  );
1653
2012
  if (tailTiming) {
@@ -1655,29 +2014,44 @@ function serveShellHit(
1655
2014
  tailTiming.completeMs = Math.round(performance.now() - tailT0);
1656
2015
  }
1657
2016
  }
1658
- if (tailTiming) publishShellTailTiming(tailTiming);
2017
+ publishTailTiming(tailTiming, metricsStore, request, url);
1659
2018
  controller.close();
1660
2019
  } catch (error) {
2020
+ if (error instanceof ShellRecordUnavailableError) {
2021
+ // The doc record could not supply the handler layer, and running
2022
+ // handlers behind a committed prelude is exactly what a HIT must
2023
+ // never do: reload the page into a forced MISS. A broken entry is
2024
+ // also replaced (tombstone, memo dropped, recapture); one whose
2025
+ // snapshot read was only slow stays, and the next request reads it
2026
+ // again.
2027
+ if ((await document.snapshotFailure) !== "unavailable") {
2028
+ await degradeUnreplayableShell(
2029
+ descriptor,
2030
+ entry,
2031
+ scheduleRecapture,
2032
+ );
2033
+ }
2034
+ controller.enqueue(new TextEncoder().encode(shellReloadScript()));
2035
+ if (tailTiming) {
2036
+ tailTiming.outcome = "error";
2037
+ tailTiming.completeMs = Math.round(performance.now() - tailT0);
2038
+ }
2039
+ publishTailTiming(tailTiming, metricsStore, request, url);
2040
+ controller.close();
2041
+ return;
2042
+ }
1661
2043
  // Self-heal on a failed tail: errors the pre-commit gates cannot catch
1662
2044
  // (mismatched postponed blob, hard render error above the holes) throw
1663
2045
  // here AFTER the 200 + prelude flushed and would re-fail on every
1664
2046
  // request until the entry ages out — recapture overwrites the entry.
1665
2047
  // Client disconnects land here too; the recapture is idempotent and
1666
2048
  // bounded by scheduleShellCapture's stampede guard + backoff.
1667
- scheduleShellCapture(
1668
- ctx,
1669
- request,
1670
- env,
1671
- url,
1672
- reqCtx,
1673
- ssrModule,
1674
- descriptor,
1675
- );
2049
+ scheduleRecapture();
1676
2050
  if (tailTiming) {
1677
2051
  tailTiming.outcome = "error";
1678
2052
  tailTiming.completeMs = Math.round(performance.now() - tailT0);
1679
- publishShellTailTiming(tailTiming);
1680
2053
  }
2054
+ publishTailTiming(tailTiming, metricsStore, request, url);
1681
2055
  controller.error(error);
1682
2056
  }
1683
2057
  },