@rangojs/router 0.16.0 → 0.18.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 (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -2,8 +2,7 @@
2
2
  * RSC Rendering Handler (Navigation)
3
3
  *
4
4
  * Handles RSC rendering for both partial (client-side navigation) and full
5
- * (initial page load) requests. Includes prerender collection for build-time
6
- * static generation.
5
+ * (initial page load) requests.
7
6
  */
8
7
 
9
8
  import {
@@ -14,9 +13,13 @@ import {
14
13
  } from "../server/request-context.js";
15
14
  import {
16
15
  SeededShellStore,
16
+ ShellRecordUnavailableError,
17
17
  buildShellLoaderSeed,
18
+ countSnapshotFamilies,
19
+ hasDocRecord,
18
20
  } from "../cache/shell-snapshot.js";
19
21
  import { appendMetric } from "../router/metrics.js";
22
+ import type { MetricsStore } from "../server/context.js";
20
23
  import { observePhase, PHASES } from "../router/instrument.js";
21
24
  import type { TraceSpan } from "../router/tracing.js";
22
25
  import { getSSRSetup, createSsrHtmlStage, isRscRequest } from "./ssr-setup.js";
@@ -46,6 +49,8 @@ import type { HandlerContext } from "./handler-context.js";
46
49
  import { gateTransitions } from "./transition-gate.js";
47
50
  import { buildFullPayload } from "./full-payload.js";
48
51
  import {
52
+ capShellWindow,
53
+ lastStoredCaptureSeq,
49
54
  scheduleShellCapture,
50
55
  resolveShellCaptureDebugSink,
51
56
  takeCaptureDebugEventForTiming,
@@ -60,7 +65,15 @@ import {
60
65
  shellSearchSeed,
61
66
  isValidShellHit,
62
67
  hasIntactShellPayload,
63
- base64ToBytes,
68
+ openShellDocument,
69
+ navigationShellKey,
70
+ partitionShellKey,
71
+ inlineShellScript,
72
+ shellReloadScript,
73
+ partitionBuildShellCheckDone,
74
+ notePartitionBuildShellCheck,
75
+ warnPprWindowCappedOnce,
76
+ SHELL_PRELUDE_CHUNK_BYTES,
64
77
  hasShellFamily,
65
78
  warnShellStoreMissingOnce,
66
79
  warnPprNonceActiveOnce,
@@ -68,13 +81,19 @@ import {
68
81
  publishShellTailTiming,
69
82
  takeShellTailTimingForServerTiming,
70
83
  type ResolvedPprConfig,
84
+ type ShellDocument,
71
85
  type ShellTailTiming,
72
86
  } from "./shell-serve.js";
73
87
  import {
88
+ hasBuildShell,
74
89
  lookupBuildShell,
75
90
  type DevShellLookup,
76
91
  } from "./shell-build-manifest.js";
77
92
  import { contextGet } from "../context-var.js";
93
+ import {
94
+ resolveSwrWindow,
95
+ staleShellRecaptureDue,
96
+ } from "../cache/cache-policy.js";
78
97
  import {
79
98
  getNavigationContextHeader,
80
99
  prerenderStoreShortCircuits,
@@ -89,13 +108,16 @@ import {
89
108
  safeSameOriginLanding,
90
109
  } from "../redirect-origin.js";
91
110
  import { nonce as nonceToken } from "./nonce.js";
111
+ import { escapeJsonForScript } from "../escape-script.js";
112
+ import { resolveShellPartition } from "../cache/cache-scope.js";
92
113
  import { reportCacheError } from "../cache/cache-error.js";
93
114
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
94
115
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
95
116
  import type {
96
- DocumentShellCacheEntry,
97
117
  SegmentCacheStore,
98
118
  ShellCacheEntry,
119
+ ShellDocumentRead,
120
+ ShellReadStats,
99
121
  ShellSnapshotRecord,
100
122
  } from "../cache/types.js";
101
123
  import {
@@ -117,8 +139,6 @@ type PprReplayBypassReason =
117
139
  | "no-entry"
118
140
  | "invalid-version"
119
141
  | "corrupt-entry"
120
- | "handler-live-holes"
121
- | "transition-when"
122
142
  | "no-segment-snapshot"
123
143
  | "snapshot-miss"
124
144
  | "explicit-cache-hit"
@@ -144,13 +164,6 @@ function describePprReplayStatus(status: PprReplayStatus): string {
144
164
  : `bypass:${status.reason}`;
145
165
  }
146
166
 
147
- function buildNavigationShellKey(
148
- url: URL,
149
- filter?: SearchParamsFilter,
150
- ): string {
151
- return `${buildShellKey(url, filter)}:navigation`;
152
- }
153
-
154
167
  function createShellCaptureDescriptor(
155
168
  ctx: HandlerContext<any>,
156
169
  key: string,
@@ -243,8 +256,6 @@ function replayableShellSnapshot(
243
256
  if (!entry.navigationOnly && !hasIntactShellPayload(entry)) {
244
257
  return { reason: "corrupt-entry" };
245
258
  }
246
- if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
247
- if (entry.transitionWhen) return { reason: "transition-when" };
248
259
  // Eligibility requires the CANONICAL doc segment record (`docKey`), not just
249
260
  // any segment record: captures under an explicit cache() scope also record
250
261
  // that tier's reads/writes under keys a partial lookup can never resolve,
@@ -252,25 +263,8 @@ function replayableShellSnapshot(
252
263
  // (a timing-dependent snapshot-miss/no-segment-snapshot flip-flop). Entries
253
264
  // captured before the field existed read as no-segment-snapshot and heal on
254
265
  // recapture (pre-release, same treatment as the buildVersion field).
255
- const docKey = entry.docKey;
256
266
  const snapshot = entry.snapshot;
257
- const hasDocRecord =
258
- docKey !== undefined &&
259
- snapshot?.some((record) => {
260
- if (
261
- !record ||
262
- typeof record !== "object" ||
263
- record.family !== "segment" ||
264
- record.key !== docKey ||
265
- typeof record.value !== "object" ||
266
- record.value === null
267
- ) {
268
- return false;
269
- }
270
- const segments = (record.value as { segments?: unknown }).segments;
271
- return Array.isArray(segments) && segments.length > 0;
272
- });
273
- return hasDocRecord && snapshot
267
+ return snapshot && hasDocRecord(snapshot, entry.docKey)
274
268
  ? { snapshot }
275
269
  : { reason: "no-segment-snapshot" };
276
270
  }
@@ -394,8 +388,8 @@ function* requestRenderPlan<TEnv>(
394
388
  const shell = yield* scope("shell-serve", shellServePlan(input));
395
389
  if (shell.kind === "serve") return shell.response;
396
390
 
397
- // Match + payload. A control response (redirect, prerender collection)
398
- // ends the plan before any rendering.
391
+ // Match + payload. A control response (redirect) ends the plan before any
392
+ // rendering.
399
393
  const prepared = yield* scope(
400
394
  isPartial ? "prepare:partial" : "prepare:full",
401
395
  preparePayloadPlan(input),
@@ -440,13 +434,14 @@ function* requestRenderPlan<TEnv>(
440
434
  response.headers.set(SHELL_STATUS_HEADER, "MISS");
441
435
  }
442
436
 
437
+ const captureKey = prepared.partialCaptureKey;
443
438
  if (
444
439
  isPartial &&
445
440
  prepared.partialCaptureNeeded &&
441
+ captureKey !== undefined &&
446
442
  !reqCtx._dynamic &&
447
443
  response.status === 200
448
444
  ) {
449
- const captureKey = prepared.partialCaptureKey;
450
445
  yield* handoff("navigation-shell-capture", () =>
451
446
  scheduleNavigationShellCapture(input, captureKey),
452
447
  );
@@ -455,6 +450,32 @@ function* requestRenderPlan<TEnv>(
455
450
  return response;
456
451
  }
457
452
 
453
+ /**
454
+ * Dev: warn once per route when the route's cache() entry reduces an explicit
455
+ * ppr window. The window is what capShellWindow gives a capture from a
456
+ * record written just now, the widest it can get; null (a cache() ttl + swr
457
+ * of 0) is the capture's own warning.
458
+ */
459
+ function warnIfPprWindowReduced(
460
+ routeName: string,
461
+ pprOption: { ttl?: number; swr?: number },
462
+ pprConfig: ResolvedPprConfig,
463
+ cacheScope: { ttl: number; swr: number | undefined },
464
+ store: SegmentCacheStore<any>,
465
+ ): void {
466
+ const cache = { ttl: cacheScope.ttl, swr: cacheScope.swr ?? 0 };
467
+ const shell = capShellWindow(
468
+ pprConfig.ttl,
469
+ resolveSwrWindow(pprConfig.swr, store.defaults),
470
+ {
471
+ freshUntil: cache.ttl * 1000,
472
+ staleUntil: (cache.ttl + cache.swr) * 1000,
473
+ },
474
+ 0,
475
+ );
476
+ if (shell) warnPprWindowCappedOnce(routeName, pprOption, shell, cache);
477
+ }
478
+
458
479
  /**
459
480
  * Axis 2 shell serve. Sync gates stay plan code; store and SSR effects run as
460
481
  * named steps. Outcomes: serve a composed shell response, report a MISS (the
@@ -470,14 +491,18 @@ function* shellServePlan<TEnv>(
470
491
  if (
471
492
  isPartial ||
472
493
  request.method !== "GET" ||
473
- url.searchParams.has("__prerender_collect") ||
474
494
  isRscRequest(request, url, false) ||
475
495
  reqCtx._dynamic
476
496
  ) {
477
497
  return { kind: "pass" };
478
498
  }
479
- const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
499
+ const manifestEntry = reqCtx._classifiedRoute?.manifestEntry;
500
+ const pprConfig = resolvePprConfig(manifestEntry);
480
501
  if (!pprConfig) return { kind: "pass" };
502
+ // A degraded HIT's reload (shellReloadScript) renders like a cache miss:
503
+ // no shell read, no capture, so it can never degrade again. The handler
504
+ // stripped the marker from the request and left this flag.
505
+ if (reqCtx._shellForcedMiss) return { kind: "pass" };
481
506
 
482
507
  // A per-request CSP nonce pins the route to axis 1: a shared shell would
483
508
  // freeze the capture request's nonce and CSP would reject it for every
@@ -486,8 +511,8 @@ function* shellServePlan<TEnv>(
486
511
  // the provider-only check missed the latter (issue #656).
487
512
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
488
513
  const store = reqCtx._cacheStore;
489
- const key = buildShellKey(url, reqCtx._searchParamsFilter);
490
- mirrorPprServerTimingsForDev(key, reqCtx);
514
+ const baseKey = buildShellKey(url, reqCtx._searchParamsFilter);
515
+ let key = baseKey;
491
516
  if (activeNonce !== undefined) {
492
517
  // Declared intent that cannot be honored deserves a diagnostic (unlike an
493
518
  // undeclared route, which is silent): a ppr route gated off by an active
@@ -501,11 +526,69 @@ function* shellServePlan<TEnv>(
501
526
  warnShellStoreMissingOnce(key);
502
527
  return { kind: "pass" };
503
528
  }
504
-
529
+ // The route's own cache() opt-out — cache(false), or a condition() that
530
+ // refuses THIS request — is absolute, and a HIT tail never consults the
531
+ // route scope (it replays the shell's doc record), so it is decided here,
532
+ // before any shell byte: the request renders like a cache miss (axis 1) and
533
+ // schedules no capture (the capture could not record the handler layer).
534
+ const route = classifiedRouteSnapshot(reqCtx);
535
+ if (route?.cacheScope && !route.cacheScope.allowsCache("read")) {
536
+ return { kind: "pass" };
537
+ }
538
+ // The shell is capped to the route cache() entry it captures from
539
+ // (capShellWindow); an explicit ppr window the cap reduces warns in dev.
540
+ // resolvePprConfig returned a config, so the entry is a ppr route.
541
+ const pprOption = manifestEntry?.type === "route" && manifestEntry.ppr;
542
+ if (
543
+ process.env.NODE_ENV !== "production" &&
544
+ route?.cacheScope?.enabled &&
545
+ typeof pprOption === "object" &&
546
+ (pprOption.ttl !== undefined || pprOption.swr !== undefined)
547
+ ) {
548
+ warnIfPprWindowReduced(
549
+ reqCtx._classifiedRoute?.routeKey ?? url.pathname,
550
+ pprOption,
551
+ pprConfig,
552
+ route.cacheScope,
553
+ store,
554
+ );
555
+ }
556
+ // A route whose cache() record is partitioned by the request (its `key()`,
557
+ // or the store's keyGenerator) partitions its shell the same way: each
558
+ // partition captures and serves its own shell. A failed key resolution
559
+ // serves no shell, never another partition's.
560
+ const partitionedKey = partitionedShellKey(
561
+ baseKey,
562
+ route?.cacheScope,
563
+ store,
564
+ reqCtx.url.pathname,
565
+ route?.params,
566
+ );
567
+ if (partitionedKey) {
568
+ const resolved = yield* step("shell-partition", () =>
569
+ partitionedKey.then(
570
+ (value): string | undefined => value,
571
+ (error: unknown) => {
572
+ reportCacheError(error, "cache-read", "[ShellServe] shell key");
573
+ return undefined;
574
+ },
575
+ ),
576
+ );
577
+ if (resolved === undefined) return { kind: "pass" };
578
+ key = resolved;
579
+ }
580
+ reqCtx._shellKey = key;
581
+ // The buffered capture and tail timings are keyed by the partitioned key.
582
+ mirrorPprServerTimings(key, reqCtx);
583
+
584
+ // A MISS schedules its capture after rendering: taken before the read, this
585
+ // lets that capture skip itself when another request's capture stored the
586
+ // shell in between (ShellCaptureDescriptor.storedSeqAtRead).
587
+ const storedSeqAtRead = lastStoredCaptureSeq(key);
505
588
  // SSR setup starts before route handling, so read the shell before joining it
506
589
  // to overlap the remaining setup work with cache I/O.
507
590
  const cached = yield* step("shell-read", () =>
508
- readShellEntry(store, key, reqCtx),
591
+ readShellEntry(store, key, reqCtx, pprConfig.tags),
509
592
  );
510
593
 
511
594
  // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
@@ -530,12 +613,21 @@ function* shellServePlan<TEnv>(
530
613
  store,
531
614
  );
532
615
 
616
+ // A HIT replays the handler layer from the entry's doc record (`docKey`),
617
+ // or, for a Prerender route, from the prerender store. An entry that has
618
+ // neither is a MISS: serving it would run handlers after the commit.
533
619
  if (
534
620
  cached &&
535
621
  !cached.entry.navigationOnly &&
622
+ (cached.entry.docKey !== undefined || route?.matched?.pr === true) &&
536
623
  isValidShellHit(cached.entry, ctx.version)
537
624
  ) {
538
- if (!hasIntactShellPayload(cached.entry)) {
625
+ const document = openShellDocumentMetered(
626
+ reqCtx,
627
+ cached.entry,
628
+ cached.read,
629
+ );
630
+ if (!document) {
539
631
  // Corrupt stored payload (undecodable prelude / unparseable
540
632
  // postponed): a store-layer fault worth a diagnostic, unlike the
541
633
  // silent version-mismatch lifecycle misses above. Degrade to MISS
@@ -550,8 +642,12 @@ function* shellServePlan<TEnv>(
550
642
  );
551
643
  } else {
552
644
  // Stale (SWR) hit: serve the stale shell now, recapture in the
553
- // background (stampede-guarded + backoff inside scheduleShellCapture).
554
- if (cached.shouldRevalidate) {
645
+ // background (stampede-guarded + backoff inside scheduleShellCapture),
646
+ // at most once per SHELL_MIN_RECAPTURE_INTERVAL_MS of the shell's age.
647
+ if (
648
+ cached.shouldRevalidate &&
649
+ staleShellRecaptureDue(cached.entry, Date.now())
650
+ ) {
555
651
  yield* handoff("shell-recapture", () =>
556
652
  scheduleShellCapture(
557
653
  ctx,
@@ -564,7 +660,6 @@ function* shellServePlan<TEnv>(
564
660
  ),
565
661
  );
566
662
  }
567
- const entry = cached.entry;
568
663
  const response = yield* step("shell-hit", () =>
569
664
  serveShellHit(
570
665
  ctx,
@@ -574,34 +669,76 @@ function* shellServePlan<TEnv>(
574
669
  reqCtx,
575
670
  handleStore,
576
671
  ssrModule,
577
- entry,
672
+ document,
578
673
  descriptor,
674
+ activeNonce,
579
675
  ),
580
676
  );
581
677
  return { kind: "serve", response };
582
678
  }
583
679
  }
584
680
 
681
+ // A runtime tombstone (degradeUnreplayableShell: this key's HIT could not
682
+ // replay its handler layer) shadows the build shell too, or a build shell
683
+ // whose tail cannot be replayed would degrade on every request until a
684
+ // runtime capture lands.
685
+ if (
686
+ cached?.entry.navigationOnly &&
687
+ isValidShellHit(cached.entry, ctx.version)
688
+ ) {
689
+ return {
690
+ kind: "miss",
691
+ descriptor: { ...descriptor, storedSeqAtRead },
692
+ ssrModule,
693
+ };
694
+ }
695
+
585
696
  // Build-time shell read-through (producer B, #699): on a runtime
586
697
  // store MISS a Prerender+ppr route serves its `vite build`-baked
587
- // shell through the SAME serve path. lookupBuildShell owns every
588
- // gate and fails to null (ordinary MISS path takes over); past
589
- // ppr.ttl the baked entry still serves while SWR recaptures — the
590
- // upgrade path from build entry to runtime entry.
591
- const buildHit = yield* step("build-shell-lookup", () =>
592
- lookupBuildShell(
593
- url,
594
- ctx.version,
595
- store,
596
- // Dev: no build manifest exists; producer B runs on demand via
597
- // the dev server's /__rsc_shell endpoint for PRERENDERED routes
598
- // only (production's exact candidate set). Folded away in
599
- // production builds (NODE_ENV is a compile-time constant).
600
- resolveDevShellLookup(reqCtx, pprConfig),
601
- reqCtx._searchParamsFilter,
602
- ),
603
- );
604
- if (buildHit) {
698
+ // shell through the SAME serve path. A partitioned route has none: the
699
+ // build captured one partition, which no request may be assumed to
700
+ // share, and a route that has a build shell says so once. lookupBuildShell
701
+ // owns the validity gates and fails to null; openShellDocument below is
702
+ // the prelude's integrity check and only decode (either failing leaves the
703
+ // ordinary MISS path); past ppr.ttl the baked entry still serves while SWR
704
+ // recaptures — the upgrade path from build entry to runtime entry.
705
+ //
706
+ // Dev: no build manifest exists; producer B runs on demand via the dev
707
+ // server's /__rsc_shell endpoint for PRERENDERED routes only (production's
708
+ // exact candidate set). Folded away in production builds (NODE_ENV is a
709
+ // compile-time constant).
710
+ const devShellLookup = resolveDevShellLookup(reqCtx, pprConfig);
711
+ let buildHit: Awaited<ReturnType<typeof lookupBuildShell>> = null;
712
+ if (key !== baseKey) {
713
+ // Probed once per path without a build shell, and not again once the
714
+ // route warned. A search-bearing URL never reads one: nothing to probe.
715
+ const routeName = reqCtx._classifiedRoute?.routeKey;
716
+ if (
717
+ !partitionBuildShellCheckDone(url.pathname, routeName) &&
718
+ shellSearchSeed(url, reqCtx._searchParamsFilter) === ""
719
+ ) {
720
+ const found = yield* step("build-shell-check", () =>
721
+ hasBuildShell(url.pathname, devShellLookup),
722
+ );
723
+ notePartitionBuildShellCheck(url.pathname, routeName, found);
724
+ }
725
+ } else {
726
+ buildHit = yield* step("build-shell-lookup", () =>
727
+ lookupBuildShell(
728
+ url,
729
+ ctx.version,
730
+ store,
731
+ devShellLookup,
732
+ reqCtx._searchParamsFilter,
733
+ ),
734
+ );
735
+ }
736
+ // The document serve decodes the baked prelude once here; an undecodable
737
+ // one is a MISS like a corrupt runtime entry.
738
+ const buildDocument = buildHit
739
+ ? openShellDocumentMetered(reqCtx, buildHit.entry)
740
+ : null;
741
+ if (buildHit && buildDocument) {
605
742
  // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
606
743
  if (buildHit.stale) {
607
744
  yield* handoff("shell-recapture", () =>
@@ -625,8 +762,9 @@ function* shellServePlan<TEnv>(
625
762
  reqCtx,
626
763
  handleStore,
627
764
  ssrModule,
628
- buildHit.entry,
765
+ buildDocument,
629
766
  descriptor,
767
+ activeNonce,
630
768
  ),
631
769
  );
632
770
  return { kind: "serve", response };
@@ -634,12 +772,16 @@ function* shellServePlan<TEnv>(
634
772
 
635
773
  // MISS (no entry, invalid reactVersion, or store read failure): axis 1
636
774
  // + a background capture scheduled once the response is known servable.
637
- return { kind: "miss", descriptor, ssrModule };
775
+ return {
776
+ kind: "miss",
777
+ descriptor: { ...descriptor, storedSeqAtRead },
778
+ ssrModule,
779
+ };
638
780
  }
639
781
 
640
782
  /**
641
783
  * Match the request and assemble the RSC payload, or produce a control
642
- * response (redirect, prerender collection) that ends the request plan.
784
+ * response (redirect) that ends the request plan.
643
785
  */
644
786
  function* preparePayloadPlan<TEnv>(
645
787
  input: RequestRenderInput<TEnv>,
@@ -750,13 +892,6 @@ function* preparePayloadPlan<TEnv>(
750
892
  // Caching is now handled in router.match() via cache provider in request context
751
893
  // match.segments already contains cached or fresh segments as appropriate
752
894
 
753
- if (url.searchParams.has("__prerender_collect")) {
754
- const response = yield* step("prerender-collect", () =>
755
- collectPrerenderArtifacts(match, handleStore),
756
- );
757
- return { kind: "control", response };
758
- }
759
-
760
895
  return {
761
896
  kind: "payload",
762
897
  payload: buildFullPayload(match, ctx, url, reqCtx, handleStore),
@@ -791,53 +926,157 @@ async function matchPartialAndRecordParams<TEnv>(
791
926
  return replay;
792
927
  }
793
928
 
929
+ /** A document serve's store read: the entry, plus the prelude-first read when the store has one. */
930
+ interface ShellStoreRead {
931
+ entry: ShellCacheEntry;
932
+ shouldRevalidate?: boolean;
933
+ read?: ShellDocumentRead;
934
+ }
935
+
794
936
  /**
795
937
  * Shell store read that degrades to null on failure (axis 1 MISS, never a 500)
796
938
  * and records the raw `ppr:shell-read` outcome (pre-validity-gates, so a
797
939
  * version-mismatch lifecycle miss stays distinguishable from a store miss).
940
+ * A store with a prelude-first read (readShellDocument) resolves before the
941
+ * snapshot is read, so the HIT commits without waiting for it.
798
942
  */
799
- async function readShellEntry<
800
- TStore extends { getShell(key: string): Promise<unknown> },
801
- >(
802
- store: TStore,
943
+ async function readShellEntry(
944
+ store: SegmentCacheStore & {
945
+ getShell: NonNullable<SegmentCacheStore["getShell"]>;
946
+ },
803
947
  key: string,
804
948
  reqCtx: ReturnType<typeof getRequestContext>,
805
- ): Promise<Awaited<ReturnType<TStore["getShell"]>> | null> {
806
- let cached: Awaited<ReturnType<TStore["getShell"]>> | null = null;
949
+ tagHints: readonly string[] | undefined,
950
+ ): Promise<ShellStoreRead | null> {
951
+ let cached: ShellStoreRead | null = null;
807
952
  const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
808
953
  try {
809
- // The constraint types the call as Promise<unknown>; the instantiated
810
- // TStore carries the real entry type, so the assertion restores it.
811
- cached = (await store.getShell(key)) as Awaited<
812
- ReturnType<TStore["getShell"]>
813
- >;
954
+ if (store.readShellDocument) {
955
+ const read = await store.readShellDocument(
956
+ key,
957
+ tagHints ? { tagHints } : undefined,
958
+ );
959
+ cached = read
960
+ ? { entry: read.entry, shouldRevalidate: read.shouldRevalidate, read }
961
+ : null;
962
+ } else {
963
+ cached = await store.getShell(key);
964
+ }
814
965
  } catch (error) {
815
966
  reportCacheError(error, "cache-read", "[ShellServe] getShell");
816
967
  }
817
968
  if (reqCtx._metricsStore) {
969
+ const stats = cached?.read?.stats;
818
970
  appendMetric(
819
971
  reqCtx._metricsStore,
820
972
  "ppr:shell-read",
821
973
  shellReadStart,
822
974
  performance.now() - shellReadStart,
823
975
  undefined,
824
- cached ? "hit" : "miss",
976
+ cached ? (stats ? `hit ${stats.tier}` : "hit") : "miss",
825
977
  );
978
+ if (stats)
979
+ recordShellReadStats(reqCtx._metricsStore, shellReadStart, stats);
826
980
  }
827
981
  return cached;
828
982
  }
829
983
 
830
984
  /**
831
- * Dev Server-Timing mirror (issue #651): a capture completes AFTER its
832
- * triggering response committed, so its outcome can only ride a LATER
833
- * response's header. Read-and-clear keeps one capture = one report;
834
- * dev-only (see takeCaptureDebugEventForTiming).
985
+ * A prelude-first read's sub-steps as `debugPerformance` rows under
986
+ * `ppr:shell-read`. The marker row starts with the prelude row because the
987
+ * two run in parallel; its desc says how long the read still waited on the
988
+ * marker once the prelude was in. On a deployed worker the clock only
989
+ * advances on I/O, so the byte counts are the cost signal for the CPU parts.
990
+ */
991
+ function recordShellReadStats(
992
+ store: MetricsStore,
993
+ readStart: number,
994
+ stats: ShellReadStats,
995
+ ): void {
996
+ let at = readStart;
997
+ const row = (label: string, ms: number | undefined, desc: string): void => {
998
+ if (ms !== undefined) appendMetric(store, label, at, ms, 1, desc);
999
+ };
1000
+ if (stats.memo) {
1001
+ row(
1002
+ "ppr:shell-memo",
1003
+ 0,
1004
+ `${stats.memo.hit ? "hit" : "miss"} size=${stats.memo.bytes}b`,
1005
+ );
1006
+ }
1007
+ // A KV hit after an L1 miss: the L1 attempt first, then the KV read.
1008
+ row("ppr:shell-l1-miss", stats.l1MissMs, stats.l1MissReason ?? "miss");
1009
+ at += stats.l1MissMs ?? 0;
1010
+ row("ppr:shell-match", stats.matchMs, stats.tier);
1011
+ at += stats.matchMs ?? 0;
1012
+ row("ppr:shell-head", stats.headMs, `bytes=${stats.headBytes ?? 0}`);
1013
+ at += stats.headMs ?? 0;
1014
+ row("ppr:shell-prelude", stats.preludeMs, `bytes=${stats.preludeBytes ?? 0}`);
1015
+ let marker = `tags=${stats.tags ?? 0} ${stats.markerSerial ? "serial" : "parallel"} commit-wait=${(stats.markerWaitMs ?? 0).toFixed(2)}ms`;
1016
+ if (stats.markerMemo) marker += ` memo=${stats.markerMemo}`;
1017
+ if (stats.markerHinted) {
1018
+ marker += ` hint=${stats.markerHintHits ?? 0}/${stats.markerHinted.length}`;
1019
+ if (stats.markerLeadMs !== undefined) {
1020
+ marker += ` lead=${stats.markerLeadMs.toFixed(2)}ms`;
1021
+ }
1022
+ }
1023
+ if (stats.freshReads) marker += " fresh-reads";
1024
+ row("ppr:shell-marker", stats.markerMs, marker);
1025
+ }
1026
+
1027
+ /**
1028
+ * openShellDocument (the HIT's integrity gate and only prelude decode) as a
1029
+ * `ppr:shell-open` row: CPU only, so it reads 0 on a deployed worker.
1030
+ */
1031
+ function openShellDocumentMetered(
1032
+ reqCtx: RequestContext<any>,
1033
+ entry: ShellCacheEntry,
1034
+ read?: ShellDocumentRead,
1035
+ ): ShellDocument | null {
1036
+ const store = reqCtx._metricsStore;
1037
+ if (!store) return openShellDocument(entry, read);
1038
+ const start = performance.now();
1039
+ const document = openShellDocument(entry, read);
1040
+ appendMetric(
1041
+ store,
1042
+ "ppr:shell-open",
1043
+ start,
1044
+ performance.now() - start,
1045
+ undefined,
1046
+ `cpu ${read ? "raw" : "base64-decode"} prelude=${document?.prelude.length ?? 0}b`,
1047
+ );
1048
+ return document;
1049
+ }
1050
+
1051
+ /**
1052
+ * The shell key a request reads and captures: `baseKey`, partitioned by the
1053
+ * route's request partition (cache-scope.ts resolveShellPartition; a
1054
+ * partition equal to the default key leaves it unchanged). Undefined,
1055
+ * synchronously and with no work, when nothing partitions the route; a
1056
+ * rejection is a failed key resolution.
1057
+ */
1058
+ function partitionedShellKey(
1059
+ baseKey: string,
1060
+ ...partition: Parameters<typeof resolveShellPartition>
1061
+ ): Promise<string> | undefined {
1062
+ return resolveShellPartition(...partition)?.then((resolved) =>
1063
+ resolved === null ? baseKey : partitionShellKey(baseKey, resolved),
1064
+ );
1065
+ }
1066
+
1067
+ /**
1068
+ * Server-Timing mirror (issue #651): a capture or a HIT tail completes AFTER
1069
+ * its triggering response committed, so its numbers can only ride a LATER
1070
+ * response's header. Read-and-clear keeps one report per run. The capture
1071
+ * half is dev-only (see takeCaptureDebugEventForTiming); the tail half runs
1072
+ * wherever a tail was buffered (serveShellHit).
835
1073
  */
836
- function mirrorPprServerTimingsForDev(
1074
+ function mirrorPprServerTimings(
837
1075
  key: string,
838
1076
  reqCtx: ReturnType<typeof getRequestContext>,
839
1077
  ): void {
840
- if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
1078
+ if (!reqCtx._metricsStore) return;
1079
+ if (process.env.NODE_ENV !== "production") {
841
1080
  const lastCapture = takeCaptureDebugEventForTiming(key);
842
1081
  if (lastCapture) {
843
1082
  appendMetric(
@@ -850,55 +1089,24 @@ function mirrorPprServerTimingsForDev(
850
1089
  describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
851
1090
  );
852
1091
  }
853
- // Same mirror for the previous HIT's tail: its per-stage numbers
854
- // (seed/match/handover/first-html/complete) finished after that
855
- // response's headers were committed, so they ride THIS request's
856
- // Server-Timing as `ppr:tail;dur=<complete ms>`.
857
- const lastTail = takeShellTailTimingForServerTiming(key);
858
- if (lastTail) {
859
- appendMetric(
860
- reqCtx._metricsStore,
861
- "ppr:tail",
862
- performance.now(),
863
- lastTail.completeMs ?? 0,
864
- undefined,
865
- // completeMs already rides as this entry's dur — drop it from desc.
866
- describeShellTailTiming({ ...lastTail, completeMs: undefined }),
867
- );
868
- }
869
1092
  }
870
- }
871
-
872
- /**
873
- * Build-time prerender collection: serialize segments and handle data
874
- * to JSON for storage as build artifacts. At runtime the worker
875
- * deserializes these and feeds them through the normal segment pipeline.
876
- */
877
- async function collectPrerenderArtifacts<TEnv>(
878
- match: Awaited<ReturnType<HandlerContext<TEnv>["router"]["match"]>>,
879
- handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
880
- ): Promise<Response> {
881
- const nonLoaderSegments = match.segments.filter((s) => s.type !== "loader");
882
- handleStore.seal();
883
- await handleStore.settled;
884
- const { serializeSegments } = await import("../cache/segment-codec.js");
885
- const serializedSegments = await serializeSegments(nonLoaderSegments);
886
- const handles: Record<string, Record<string, unknown[]>> = {};
887
- for (const seg of nonLoaderSegments) {
888
- const segHandles = handleStore.getDataForSegment(seg.id);
889
- if (Object.keys(segHandles).length > 0) {
890
- handles[seg.id] = segHandles;
891
- }
1093
+ // Same mirror for the previous HIT's tail: its per-stage numbers
1094
+ // (snapshot/seed/match/handover/first-html/complete) finished after that
1095
+ // response's headers were committed, so they ride THIS request's
1096
+ // Server-Timing as `ppr:tail;dur=<complete ms>`. In production a tail is
1097
+ // buffered only when its own HIT collected metrics (serveShellHit).
1098
+ const lastTail = takeShellTailTimingForServerTiming(key);
1099
+ if (lastTail) {
1100
+ appendMetric(
1101
+ reqCtx._metricsStore,
1102
+ "ppr:tail",
1103
+ performance.now(),
1104
+ lastTail.completeMs ?? 0,
1105
+ undefined,
1106
+ // completeMs already rides as this entry's dur — drop it from desc.
1107
+ describeShellTailTiming({ ...lastTail, completeMs: undefined }),
1108
+ );
892
1109
  }
893
- return new Response(
894
- JSON.stringify({
895
- segments: serializedSegments,
896
- handles,
897
- routeName: match.routeName,
898
- params: match.params,
899
- }),
900
- { headers: { "Content-Type": "application/json" } },
901
- );
902
1110
  }
903
1111
 
904
1112
  /** Assemble headers + tracking and run the foreground stage-driver render. */
@@ -985,7 +1193,7 @@ function renderPreparedRscResponse<TEnv>(
985
1193
  */
986
1194
  function scheduleNavigationShellCapture<TEnv>(
987
1195
  input: RequestRenderInput<TEnv>,
988
- partialCaptureKey: string | undefined,
1196
+ captureKey: string,
989
1197
  ): void {
990
1198
  const { ctx, request, env, url, reqCtx } = input;
991
1199
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
@@ -1008,8 +1216,7 @@ function scheduleNavigationShellCapture<TEnv>(
1008
1216
  },
1009
1217
  createShellCaptureDescriptor(
1010
1218
  ctx,
1011
- partialCaptureKey ??
1012
- buildNavigationShellKey(url, reqCtx._searchParamsFilter),
1219
+ captureKey,
1013
1220
  shellSearchSeed(url, reqCtx._searchParamsFilter),
1014
1221
  url.origin,
1015
1222
  pprConfig,
@@ -1168,8 +1375,26 @@ async function matchPartialWithPprReplay<TEnv>(
1168
1375
  return runMatch({ outcome: "BYPASS", reason: "cache-disabled" });
1169
1376
  }
1170
1377
 
1171
- const key = buildShellKey(url, reqCtx._searchParamsFilter);
1172
- const navigationKey = `${key}:navigation`;
1378
+ // The visitor's own partition (resolveShellPartition): replay never
1379
+ // crosses into another partition's shell.
1380
+ const baseKey = buildShellKey(url, reqCtx._searchParamsFilter);
1381
+ let key = baseKey;
1382
+ const partitionedKey = partitionedShellKey(
1383
+ baseKey,
1384
+ routeCacheScope,
1385
+ store,
1386
+ reqCtx.url.pathname,
1387
+ routeSnapshot?.params,
1388
+ );
1389
+ if (partitionedKey) {
1390
+ try {
1391
+ key = await partitionedKey;
1392
+ } catch (error) {
1393
+ reportCacheError(error, "cache-read", "[NavigationPPR] shell key");
1394
+ return runMatch({ outcome: "BYPASS", reason: "read-error" });
1395
+ }
1396
+ }
1397
+ const navigationKey = navigationShellKey(key);
1173
1398
  let cached: Awaited<ReturnType<typeof store.getShell>> = null;
1174
1399
  try {
1175
1400
  cached = await store.getShell(key, { claimRevalidation: false });
@@ -1193,11 +1418,7 @@ async function matchPartialWithPprReplay<TEnv>(
1193
1418
  }
1194
1419
  }
1195
1420
 
1196
- if (
1197
- !snapshot &&
1198
- bypassReason !== "handler-live-holes" &&
1199
- bypassReason !== "transition-when"
1200
- ) {
1421
+ if (!snapshot) {
1201
1422
  let navigationCached: Awaited<ReturnType<typeof store.getShell>> = null;
1202
1423
  try {
1203
1424
  navigationCached = await store.getShell(navigationKey, {
@@ -1226,13 +1447,16 @@ async function matchPartialWithPprReplay<TEnv>(
1226
1447
  // Production build manifests are local module data. In dev, resolving a
1227
1448
  // missing build shell would foreground-fetch /__rsc_shell and block an
1228
1449
  // otherwise ordinary navigation on capture, so replay remains runtime-only.
1229
- const buildHit = await lookupBuildShell(
1230
- url,
1231
- ctx.version,
1232
- store,
1233
- undefined,
1234
- reqCtx._searchParamsFilter,
1235
- );
1450
+ const buildHit =
1451
+ key !== baseKey
1452
+ ? null
1453
+ : await lookupBuildShell(
1454
+ url,
1455
+ ctx.version,
1456
+ store,
1457
+ undefined,
1458
+ reqCtx._searchParamsFilter,
1459
+ );
1236
1460
  if (buildHit?.stale) {
1237
1461
  bypassReason ??= "stale-build-entry";
1238
1462
  } else if (buildHit) {
@@ -1300,6 +1524,7 @@ async function matchPartialWithPprReplay<TEnv>(
1300
1524
  reqCtx._pprReplayPostMatchReason === undefined &&
1301
1525
  (shouldHealReplayMiss(bypassReason) ||
1302
1526
  bypassReason === "no-segment-snapshot"),
1527
+ captureKey: navigationKey,
1303
1528
  };
1304
1529
  } finally {
1305
1530
  reqCtx._shellImplicitCache = previousImplicitCache;
@@ -1314,10 +1539,13 @@ async function matchPartialWithPprReplay<TEnv>(
1314
1539
  captureNeeded:
1315
1540
  reqCtx._pprReplayPostMatchReason === undefined &&
1316
1541
  shouldHealReplayMiss(bypassReason),
1542
+ captureKey: navigationKey,
1317
1543
  };
1318
1544
  }
1319
1545
 
1320
1546
  const previousImplicitCache = reqCtx._shellImplicitCache;
1547
+ const previousLoaderSeed = reqCtx._shellLoaderSeed;
1548
+ let loaderSeed: ReturnType<typeof buildShellLoaderSeed> | undefined;
1321
1549
  let segmentReplayHit = false;
1322
1550
  let segmentReplayCorrupt = false;
1323
1551
  let explicitCacheHit = false;
@@ -1329,8 +1557,18 @@ async function matchPartialWithPprReplay<TEnv>(
1329
1557
  segmentsOnly: true,
1330
1558
  }),
1331
1559
  keyPrefix: "doc",
1332
- onHit: () => {
1560
+ // Arms the bake-lane loader pins a document HIT serves (loader-cache.ts
1561
+ // resolveLoaderData), decoded only now: the lookup awaits this before
1562
+ // the route's loaders resolve, and an explicit-tier hit or a miss runs
1563
+ // the loaders fresh and never reads a pin.
1564
+ onHit: async () => {
1333
1565
  segmentReplayHit = true;
1566
+ // Never rejects: the lookup awaits this inside its own try, and an
1567
+ // unreadable seed leaves those loaders to run fresh.
1568
+ const seed = await (loaderSeed ??= buildShellLoaderSeed(snapshot).catch(
1569
+ () => undefined,
1570
+ ));
1571
+ if (seed) reqCtx._shellLoaderSeed = seed;
1334
1572
  },
1335
1573
  onCorrupt: () => {
1336
1574
  segmentReplayCorrupt = true;
@@ -1378,6 +1616,7 @@ async function matchPartialWithPprReplay<TEnv>(
1378
1616
  };
1379
1617
  } finally {
1380
1618
  reqCtx._shellImplicitCache = previousImplicitCache;
1619
+ reqCtx._shellLoaderSeed = previousLoaderSeed;
1381
1620
  }
1382
1621
  }
1383
1622
 
@@ -1422,6 +1661,75 @@ export function resolveShellHitRedirectTarget(
1422
1661
  );
1423
1662
  }
1424
1663
 
1664
+ /**
1665
+ * Buffer a finished tail timing for the next request's Server-Timing, and
1666
+ * under debugPerformance print it for this request (whose own Server-Timing
1667
+ * was sent at the commit, before the tail ran).
1668
+ */
1669
+ function publishTailTiming(
1670
+ tailTiming: ShellTailTiming | null,
1671
+ metricsStore: MetricsStore | undefined,
1672
+ request: Request,
1673
+ url: URL,
1674
+ ): void {
1675
+ if (!tailTiming) return;
1676
+ publishShellTailTiming(tailTiming);
1677
+ if (metricsStore) {
1678
+ console.log(
1679
+ `[RSC Perf] ${request.method} ${url.pathname} shell tail: ${describeShellTailTiming(tailTiming)}`,
1680
+ );
1681
+ }
1682
+ }
1683
+
1684
+ const PENDING = Symbol("pending");
1685
+
1686
+ /**
1687
+ * Whether `value` is not a promise, or a promise that has already settled:
1688
+ * racing it against an already-resolved promise, a settled one's reaction is
1689
+ * queued first and wins.
1690
+ */
1691
+ async function isSettled(value: unknown): Promise<boolean> {
1692
+ if (!(value instanceof Promise)) return true;
1693
+ return Promise.race([value, PENDING]).then(
1694
+ (first) => first !== PENDING,
1695
+ () => true,
1696
+ );
1697
+ }
1698
+
1699
+ /**
1700
+ * Evict a shell whose doc record cannot be replayed. The store has no shell
1701
+ * delete, so the entry is overwritten with a tombstone: a navigationOnly
1702
+ * entry with no document half and no snapshot, which document serving
1703
+ * treats as a MISS (shellServePlan, the build shell included) and partial
1704
+ * replay as `no-segment-snapshot`. The recapture overwrites it with a sound
1705
+ * entry. Only for a broken entry: a snapshot read that was merely slow
1706
+ * (`snapshotFailure: "unavailable"`) leaves the entry alone.
1707
+ */
1708
+ async function degradeUnreplayableShell(
1709
+ descriptor: ShellCaptureDescriptor,
1710
+ entry: ShellCacheEntry,
1711
+ scheduleRecapture: () => void,
1712
+ ): Promise<void> {
1713
+ const store = descriptor.store;
1714
+ try {
1715
+ await store?.putShell?.(
1716
+ descriptor.key,
1717
+ {
1718
+ reactVersion: entry.reactVersion,
1719
+ buildVersion: entry.buildVersion,
1720
+ navigationOnly: true,
1721
+ createdAt: Date.now(),
1722
+ },
1723
+ descriptor.ttl,
1724
+ 0,
1725
+ );
1726
+ } catch (error) {
1727
+ reportCacheError(error, "cache-write", "[ShellServe] tombstone");
1728
+ }
1729
+ store?.dropShellMemo?.(descriptor.key);
1730
+ scheduleRecapture();
1731
+ }
1732
+
1425
1733
  /**
1426
1734
  * Serve a validated shell HIT: commit the stored prelude bytes NOW and run the
1427
1735
  * live tail behind them inside the response stream. Plain byte concatenation is
@@ -1439,15 +1747,18 @@ function serveShellHit(
1439
1747
  reqCtx: RequestContext<any>,
1440
1748
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
1441
1749
  ssrModule: SSRModule,
1442
- entry: DocumentShellCacheEntry,
1750
+ document: ShellDocument,
1443
1751
  descriptor: ShellCaptureDescriptor,
1752
+ nonce: string | undefined,
1444
1753
  ): Response {
1445
- const preludeBytes = base64ToBytes(entry.prelude);
1446
- // Per-stage tail timing for the dev `ppr:tail` Server-Timing mirror. Dev
1447
- // only (NODE_ENV folds the branch away in production builds); offsets are
1448
- // relative to this commit point.
1754
+ const { entry, prelude: preludeBytes } = document;
1755
+ const metricsStore = reqCtx._metricsStore;
1756
+ // Per-stage tail timing for the `ppr:tail` Server-Timing mirror and the
1757
+ // perf console line: always in dev, in production only when this request
1758
+ // collects debugPerformance metrics. Offsets are relative to this commit
1759
+ // point.
1449
1760
  const tailTiming: ShellTailTiming | null =
1450
- process.env.NODE_ENV !== "production"
1761
+ process.env.NODE_ENV !== "production" || metricsStore
1451
1762
  ? {
1452
1763
  key: descriptor.key,
1453
1764
  outcome: "complete",
@@ -1455,6 +1766,8 @@ function serveShellHit(
1455
1766
  }
1456
1767
  : null;
1457
1768
  const tailT0 = tailTiming ? performance.now() : 0;
1769
+ const scheduleRecapture = (): void =>
1770
+ scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
1458
1771
 
1459
1772
  const createTailContext = (): RequestContext<any> => {
1460
1773
  const tailCtx: RequestContext<any> = Object.create(reqCtx);
@@ -1527,13 +1840,17 @@ function serveShellHit(
1527
1840
  }
1528
1841
  return observePhase(PHASES.ssr, () =>
1529
1842
  ssrModule.resumeShellHTML!(rscStream, {
1530
- postponed: entry.postponed,
1843
+ postponed: document.postponed,
1531
1844
  nonce: undefined,
1532
1845
  // The shell key's own search — identical to the capture seed for
1533
1846
  // this key, so the resume tree matches the captured tree.
1534
1847
  search: shellSearchSeed(url, reqCtx._searchParamsFilter),
1535
1848
  // The HIT request's origin — same host as the capture's (key-scoped).
1536
1849
  origin: url.origin,
1850
+ // The document cache reads the list before storing this composite.
1851
+ onError: (error) => {
1852
+ reqCtx._renderErrors?.push(error);
1853
+ },
1537
1854
  }),
1538
1855
  );
1539
1856
  };
@@ -1546,64 +1863,78 @@ function serveShellHit(
1546
1863
  // values via a SeededShellStore overlay while unpinned reads (the holes)
1547
1864
  // stay live. The overlay lives on a DERIVED context so the shared reqCtx is
1548
1865
  // untouched.
1549
- if (entry.snapshot && entry.snapshot.length > 0) {
1550
- const seededCtx = createTailContext();
1551
- if (reqCtx._cacheStore) {
1552
- seededCtx._cacheStore = new SeededShellStore(
1553
- reqCtx._cacheStore,
1554
- entry.snapshot,
1555
- );
1556
- }
1557
- // Loader-family records (bake-lane containers, loader-container-bake):
1558
- // decode into a seed Map for the resolveLoaderData overlay, so the
1559
- // payload's baked container bytes match the frozen prelude while the
1560
- // hole-marker paths keep the fresh run's live nested promises.
1561
- const seedStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
1562
- const loaderSeed = await buildShellLoaderSeed(entry.snapshot);
1563
- if (tailTiming) {
1564
- tailTiming.seedMs = Math.round(performance.now() - tailT0);
1565
- }
1566
- if (INTERNAL_RANGO_DEBUG) {
1567
- console.log(
1568
- `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
1569
- );
1570
- }
1571
- if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
1572
- // Shell fast path (serve side): if the capture recorded the implicit doc
1573
- // segment record and the handler layer declared no liveness, the tail's
1574
- // cache lookup HITs the seeded doc entry — the handler layer is REPLAYED,
1575
- // not re-executed (loaders still run fresh). Anything else degrades to
1576
- // the full tail automatically.
1577
- if (!entry.handlerLiveHoles && !entry.transitionWhen) {
1578
- seededCtx._shellImplicitCache = {
1579
- ttl: descriptor.ttl,
1580
- swr: descriptor.swr,
1581
- keyPrefix: "doc",
1582
- };
1583
- if (INTERNAL_RANGO_DEBUG) {
1584
- console.log(
1585
- `[Server][ppr] shell HIT: fast path armed (implicit doc cache) (abs ${Math.round(performance.now())})`,
1586
- );
1587
- }
1588
- } else if (INTERNAL_RANGO_DEBUG) {
1589
- console.log(
1590
- `[Server][ppr] shell HIT: fast path declined — request-dependent handler/transition state; tail re-runs handlers (abs ${Math.round(performance.now())})`,
1591
- );
1866
+ // A prelude-first read delivers the snapshot on its own promise: the
1867
+ // prelude is already committed, so only the tail waits for it.
1868
+ const inMemory = await isSettled(document.snapshot);
1869
+ const snapshot = await document.snapshot;
1870
+ // A snapshot already in memory (shell memo, memory store, build shells)
1871
+ // resolves at once, and the tail's seeding and match would run ahead of
1872
+ // the runtime writing the prelude: one macrotask first. A snapshot still
1873
+ // arriving on I/O has yielded by the time it resolves.
1874
+ if (inMemory) await new Promise<void>((resolve) => setTimeout(resolve, 0));
1875
+ if (tailTiming) {
1876
+ tailTiming.snapshotMs = Math.round(performance.now() - tailT0);
1877
+ if (snapshot)
1878
+ tailTiming.snapshotRecords = countSnapshotFamilies(snapshot);
1879
+ tailTiming.snapshotPruned = entry.prunedRecords;
1880
+ const snapshotStats = document.stats?.snapshot;
1881
+ if (snapshotStats) {
1882
+ tailTiming.snapshotReadMs = Math.round(snapshotStats.readMs);
1883
+ tailTiming.snapshotBytes = snapshotStats.bytes;
1884
+ tailTiming.snapshotParseMs = Math.round(snapshotStats.parseMs);
1592
1885
  }
1593
- // Fragment splice (issue #700): store hits in THIS tail emit their stored
1594
- // segment fragments verbatim (expanded by segment-fragments.ts). The flag
1595
- // lives on the derived context so it can never leak into a capture render,
1596
- // which serializes segments and must see real elements.
1597
- seededCtx._shellFragmentPayload = true;
1598
- return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
1599
1886
  }
1600
- // No snapshot (e.g. a producer B entry): still a shell-HIT tail, so arm the
1601
- // fragment splice on a derived context — never the shared reqCtx, from which
1602
- // scheduleShellCapture derives the capture context (the flag must not be
1603
- // inherited there).
1604
- const fragmentCtx = createTailContext();
1605
- fragmentCtx._shellFragmentPayload = true;
1606
- return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
1887
+ const seededCtx = createTailContext();
1888
+ const records = snapshot ?? [];
1889
+ if (reqCtx._cacheStore && records.length > 0) {
1890
+ seededCtx._cacheStore = new SeededShellStore(reqCtx._cacheStore, records);
1891
+ }
1892
+ // Loader-family records (bake-lane containers, loader-container-bake):
1893
+ // decode into a seed Map for the resolveLoaderData overlay, so the
1894
+ // payload's baked container bytes match the frozen prelude while the
1895
+ // hole-marker paths keep the fresh run's live nested promises.
1896
+ const seedStart =
1897
+ INTERNAL_RANGO_DEBUG || tailTiming ? performance.now() : 0;
1898
+ const loaderSeed =
1899
+ records.length > 0 ? await buildShellLoaderSeed(records) : undefined;
1900
+ if (tailTiming) {
1901
+ const seededAt = performance.now();
1902
+ tailTiming.seedMs = Math.round(seededAt - tailT0);
1903
+ tailTiming.seedCpuMs = Math.round(seededAt - seedStart);
1904
+ }
1905
+ if (INTERNAL_RANGO_DEBUG) {
1906
+ console.log(
1907
+ `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
1908
+ );
1909
+ }
1910
+ if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
1911
+ // The handler layer is REPLAYED from the entry's own doc record, never
1912
+ // executed: the implicit doc scope replaces any route-derived scope
1913
+ // (docTail), looks the record up by the key the capture wrote
1914
+ // (fixedDocKey), and reads it from a segment-only overlay of the snapshot
1915
+ // (the real store never holds a doc: record). A lookup that does not hit
1916
+ // throws ShellRecordUnavailableError (withCacheLookup), handled by the
1917
+ // degrade below. Live and promise-carrying bake-lane loaders still run
1918
+ // fresh; a promise-free bake-lane loader is served from its pin
1919
+ // (_shellLoaderSeed, loader-cache.ts). A Prerender route's tail takes
1920
+ // the handler layer from the prerender store before this scope is
1921
+ // consulted.
1922
+ seededCtx._shellImplicitCache = {
1923
+ ttl: descriptor.ttl,
1924
+ swr: descriptor.swr,
1925
+ store: new SeededShellStore(reqCtx._cacheStore!, records, {
1926
+ segmentsOnly: true,
1927
+ }),
1928
+ keyPrefix: "doc",
1929
+ docTail: true,
1930
+ fixedDocKey: entry.docKey,
1931
+ };
1932
+ // Fragment splice (issue #700): store hits in THIS tail emit their stored
1933
+ // segment fragments verbatim (expanded by segment-fragments.ts). The flag
1934
+ // lives on the derived context so it can never leak into a capture render,
1935
+ // which serializes segments and must see real elements.
1936
+ seededCtx._shellFragmentPayload = true;
1937
+ return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
1607
1938
  })();
1608
1939
  // The stream below is the only consumer; pre-attach a no-op catch so a tail
1609
1940
  // failure before the stream is pulled never surfaces as an unhandled rejection.
@@ -1612,7 +1943,31 @@ function serveShellHit(
1612
1943
  const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
1613
1944
  const body = new ReadableStream<Uint8Array>({
1614
1945
  async start(controller) {
1615
- controller.enqueue(preludeBytes);
1946
+ // Fixed-size chunks (SHELL_PRELUDE_CHUNK_BYTES): a compressor in front
1947
+ // of the worker emits after each chunk instead of after the whole
1948
+ // prelude. slice() gives every chunk its own buffer.
1949
+ const commitStart = metricsStore ? performance.now() : 0;
1950
+ let chunks = 0;
1951
+ for (
1952
+ let offset = 0;
1953
+ offset < preludeBytes.length;
1954
+ offset += SHELL_PRELUDE_CHUNK_BYTES
1955
+ ) {
1956
+ controller.enqueue(
1957
+ preludeBytes.slice(offset, offset + SHELL_PRELUDE_CHUNK_BYTES),
1958
+ );
1959
+ chunks++;
1960
+ }
1961
+ if (metricsStore) {
1962
+ appendMetric(
1963
+ metricsStore,
1964
+ "ppr:shell-commit",
1965
+ commitStart,
1966
+ performance.now() - commitStart,
1967
+ undefined,
1968
+ `cpu chunks=${chunks} prelude=${preludeBytes.length}b`,
1969
+ );
1970
+ }
1616
1971
  if (INTERNAL_RANGO_DEBUG) {
1617
1972
  console.log(
1618
1973
  `[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
@@ -1684,7 +2039,10 @@ function serveShellHit(
1684
2039
  );
1685
2040
  controller.enqueue(
1686
2041
  new TextEncoder().encode(
1687
- `<script>location.replace(${JSON.stringify(safeTarget)})</script>`,
2042
+ inlineShellScript(
2043
+ `location.replace(${escapeJsonForScript(JSON.stringify(safeTarget))})`,
2044
+ nonce,
2045
+ ),
1688
2046
  ),
1689
2047
  );
1690
2048
  if (tailTiming) {
@@ -1692,9 +2050,34 @@ function serveShellHit(
1692
2050
  tailTiming.completeMs = Math.round(performance.now() - tailT0);
1693
2051
  }
1694
2052
  }
1695
- if (tailTiming) publishShellTailTiming(tailTiming);
2053
+ publishTailTiming(tailTiming, metricsStore, request, url);
1696
2054
  controller.close();
1697
2055
  } catch (error) {
2056
+ if (error instanceof ShellRecordUnavailableError) {
2057
+ // The doc record could not supply the handler layer, and running
2058
+ // handlers behind a committed prelude is exactly what a HIT must
2059
+ // never do: reload the page into a forced MISS. A broken entry is
2060
+ // also replaced (tombstone, memo dropped, recapture); one whose
2061
+ // snapshot read was only slow stays, and the next request reads it
2062
+ // again.
2063
+ if ((await document.snapshotFailure) !== "unavailable") {
2064
+ await degradeUnreplayableShell(
2065
+ descriptor,
2066
+ entry,
2067
+ scheduleRecapture,
2068
+ );
2069
+ }
2070
+ controller.enqueue(
2071
+ new TextEncoder().encode(shellReloadScript(nonce)),
2072
+ );
2073
+ if (tailTiming) {
2074
+ tailTiming.outcome = "error";
2075
+ tailTiming.completeMs = Math.round(performance.now() - tailT0);
2076
+ }
2077
+ publishTailTiming(tailTiming, metricsStore, request, url);
2078
+ controller.close();
2079
+ return;
2080
+ }
1698
2081
  // Self-heal on a failed tail: errors the pre-commit gates cannot catch
1699
2082
  // (mismatched postponed blob, hard render error above the holes) throw
1700
2083
  // here AFTER the 200 + prelude flushed and would re-fail on every
@@ -1713,8 +2096,8 @@ function serveShellHit(
1713
2096
  if (tailTiming) {
1714
2097
  tailTiming.outcome = "error";
1715
2098
  tailTiming.completeMs = Math.round(performance.now() - tailT0);
1716
- publishShellTailTiming(tailTiming);
1717
2099
  }
2100
+ publishTailTiming(tailTiming, metricsStore, request, url);
1718
2101
  controller.error(error);
1719
2102
  }
1720
2103
  },