@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
@@ -6,21 +6,29 @@
6
6
  * from the route's `ppr` path option after the served response is built and calls
7
7
  * scheduleShellCapture. The capture then runs as a background task that re-derives
8
8
  * the page via `ctx.router.match()` under its OWN derived request context — fresh
9
- * handle store, `_shellCaptureRun: true` so loaders mask (loader-mask.ts) and every
10
- * loading() subtree postpones. The render is MIXED-CHAIN: cache()'d segments replay
11
- * from ring 3, uncached segments execute their handlers fresh. It drives the static
12
- * prerender to a quiescent shell, aborts to freeze the prelude + postponed state,
13
- * and stores the pair via putShell. Because it uses match() rather than the HTTP
14
- * pipeline, the middleware chain (auth, logging) never re-runs — it already ran for
15
- * the triggering request, and the derived context inherits its post-middleware
16
- * state (variables, cache store). Guarding is serve-time.
9
+ * handle store, `_shellCaptureRun: true` so live-lane loaders mask (loader-mask.ts)
10
+ * and postpone at their loading() or inline <Suspense> boundary. The match is
11
+ * MIXED-CHAIN: cache()'d segments replay from ring 3, uncached segments execute
12
+ * their handlers. The record-first step (settleCaptureRecord) waits for the
13
+ * handler layer, writes the doc record every HIT replays, and renders the
14
+ * capture's Flight payload from it; the static prerender runs to a quiescent
15
+ * shell, aborts to freeze the prelude + postponed state, and putShell stores the
16
+ * pair with the snapshot. Because it uses match() rather than the HTTP pipeline,
17
+ * the middleware chain (auth, logging) never re-runs — it already ran for the
18
+ * triggering request, and the derived context inherits its post-middleware state
19
+ * (variables, cache store). Guarding is serve-time.
17
20
  */
18
21
 
19
22
  import React from "react";
20
- import { isLoaderDataResult } from "../types.js";
21
- import { bufferToBase64 } from "../cache/cf/cf-base64.js";
23
+ import { isLoaderDataResult, type MatchResult } from "../types.js";
24
+ import { bytesToBase64 } from "../cache/cf/cf-base64.js";
25
+ import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
22
26
  import { reportCacheError } from "../cache/cache-error.js";
23
- import { runBackground } from "../cache/background-task.js";
27
+ import {
28
+ raceDeadline,
29
+ runBackground,
30
+ settleGrowing,
31
+ } from "../cache/background-task.js";
24
32
  import {
25
33
  CaptureQueueFullError,
26
34
  CaptureQueueWaitTimeoutError,
@@ -31,6 +39,8 @@ import {
31
39
  PPR_LANE_HINT,
32
40
  SHELL_CAPTURE_MAX_WAIT_MS,
33
41
  SHELL_CAPTURE_TASK_HARD_CAP_MS,
42
+ resetShellWarningsForTests,
43
+ warnOnce,
34
44
  } from "./shell-capture-constants.js";
35
45
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
36
46
  import { observePhase, PHASES } from "../router/instrument.js";
@@ -41,18 +51,28 @@ import {
41
51
  wireRenderBarrier,
42
52
  UNTRACKED_BACKGROUND_TASK,
43
53
  type RequestContext,
54
+ type RouteRecordWindow,
44
55
  } from "../server/request-context.js";
56
+ import {
57
+ DEFAULT_FUNCTION_TTL,
58
+ resolveSwrWindow,
59
+ resolveTtl,
60
+ } from "../cache/cache-policy.js";
45
61
  import { createHandleStore, type HandleStore } from "../server/handle-store.js";
46
62
  import {
47
63
  maskNestedContainerThenables,
64
+ settleNestedThenables,
48
65
  type MaskReport,
49
66
  } from "../router/segment-resolution/mask-nested.js";
50
67
  import {
68
+ findEnclosingLoaderBody,
51
69
  getCurrentLoaderBodyId,
52
70
  isInsideLoaderScope,
53
71
  } from "../server/context.js";
54
72
  import { isThenable } from "../handles/is-thenable.js";
73
+ import { requestHeaders } from "../server/request-headers.js";
55
74
  import type {
75
+ CachedEntryData,
56
76
  ShellCacheEntry,
57
77
  SegmentCacheStore,
58
78
  ShellSnapshotRecord,
@@ -64,11 +84,15 @@ import {
64
84
  import {
65
85
  RecordingShellStore,
66
86
  SnapshotOnlySegmentStore,
87
+ countSnapshotFamilies,
88
+ estimateShellEntryBytes,
67
89
  getRecordingStore,
90
+ hasDocRecord,
91
+ pruneShellSnapshot,
68
92
  } from "../cache/shell-snapshot.js";
69
93
  import type { HandlerContext } from "./handler-context.js";
70
94
  import type { SSRModule } from "./types.js";
71
- import { buildFullPayload } from "./full-payload.js";
95
+ import { buildFullPayload, payloadInitialTheme } from "./full-payload.js";
72
96
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
73
97
  import { renderRscFlightStage } from "./render-pipeline.js";
74
98
  import { stripInternalParams } from "../router/handler-context.js";
@@ -89,21 +113,13 @@ import { stripInternalParams } from "../router/handler-context.js";
89
113
  *
90
114
  * K=2 gives a race window of ~two event-loop turns: shell work still producing
91
115
  * bytes keeps resetting the counter; anything not producing bytes within the
92
- * window (the masked loaders, and any genuinely pending I/O) becomes a hole. The
93
- * only residual is raw per-request I/O rendered directly in shell (not via a
94
- * loader) that resolves inside the window — a documented shell anti-pattern; put
95
- * per-request data in loaders. See docs/design/ppr-shell-resume.md.
116
+ * window (the masked loaders) becomes a hole. Handler output never races it: the
117
+ * capture's Flight input is the doc record's fragments, rendered after
118
+ * settleCaptureRecord waited for the handler layer. See
119
+ * docs/design/ppr-shell-resume.md.
96
120
  */
97
121
  const FLIGHT_QUIET_HOPS = 2;
98
122
 
99
- /**
100
- * Default capture budget. Canonical value, raise rationale, and ceiling math
101
- * live on the leaf module (shell-capture-constants.ts, importable from the ssr
102
- * graph too). Re-exported here so shell-build-manifest.ts's envelope math
103
- * keeps its existing import site and cannot drift from the capture.
104
- */
105
- export { SHELL_CAPTURE_MAX_WAIT_MS };
106
-
107
123
  /**
108
124
  * Upper bound on waiting for the capture's DEFERRED cache writes to settle before
109
125
  * draining the snapshot. Cache writes run under waitUntil (fire-and-forget on
@@ -140,33 +156,17 @@ const SHELL_CAPTURE_WRITE_BARRIER_MS = 1500;
140
156
 
141
157
  /**
142
158
  * Settle the tracked background tasks on `reqCtx._pendingBackgroundTasks`,
143
- * ITERATIVELY: a settled task can have scheduled a nested one (cache-store's
144
- * cacheRoute outer task schedules the actual store.set in a second waitUntil), so
145
- * each awaited batch may append more. Loop until no new tasks appear or the
146
- * deadline passes. The capture's own task never enters the list
147
- * (UNTRACKED_BACKGROUND_TASK), so the loop terminates.
159
+ * including nested ones a settled task scheduled (cache-store's cacheRoute
160
+ * outer task schedules the actual store.set in a second waitUntil), until the
161
+ * list stops growing or the timeout passes. The capture's own task never
162
+ * enters the list (UNTRACKED_BACKGROUND_TASK), so the wait terminates.
148
163
  */
149
164
  async function settleTrackedBackgroundTasks(
150
165
  reqCtx: RequestContext<any>,
151
166
  timeoutMs: number,
152
167
  ): Promise<void> {
153
168
  const tasks = reqCtx._pendingBackgroundTasks;
154
- if (!tasks) return;
155
- const deadline = Date.now() + timeoutMs;
156
- let seen = 0;
157
- while (tasks.length > seen) {
158
- const remaining = deadline - Date.now();
159
- if (remaining <= 0) return;
160
- const batch = tasks.slice(seen);
161
- seen = tasks.length;
162
- let timer: ReturnType<typeof setTimeout> | undefined;
163
- const guard = new Promise<void>((resolve) => {
164
- timer = setTimeout(resolve, remaining);
165
- (timer as { unref?: () => void }).unref?.();
166
- });
167
- await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
168
- if (timer) clearTimeout(timer);
169
- }
169
+ if (tasks) await settleGrowing(tasks, Date.now() + timeoutMs);
170
170
  }
171
171
 
172
172
  /**
@@ -196,31 +196,18 @@ function delay(ms: number): Promise<void> {
196
196
  * Bound one capture task at {@link SHELL_CAPTURE_TASK_HARD_CAP_MS}. Rejects on
197
197
  * expiry so the caller's existing catch applies backoff + reporting and its
198
198
  * settle path releases the stampede guard and queue slot; resolves/rejects
199
- * transparently otherwise. The timer is unref'd so a pending cap never holds a
200
- * Node dev process open.
199
+ * transparently otherwise.
201
200
  */
202
- function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
203
- return new Promise<T>((resolve, reject) => {
204
- const timer = setTimeout(() => {
205
- reject(
206
- new Error(
207
- `capture task for ${key} exceeded the ${SHELL_CAPTURE_TASK_HARD_CAP_MS}ms hard cap; ` +
208
- `a handler is likely wedged on a never-settling await`,
209
- ),
210
- );
211
- }, SHELL_CAPTURE_TASK_HARD_CAP_MS);
212
- (timer as { unref?: () => void }).unref?.();
213
- task.then(
214
- (value) => {
215
- clearTimeout(timer);
216
- resolve(value);
217
- },
218
- (error: unknown) => {
219
- clearTimeout(timer);
220
- reject(error);
221
- },
222
- );
223
- });
201
+ async function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
202
+ const raced = await raceDeadline(
203
+ task,
204
+ Date.now() + SHELL_CAPTURE_TASK_HARD_CAP_MS,
205
+ );
206
+ if (raced.done) return raced.value;
207
+ throw new Error(
208
+ `capture task for ${key} exceeded the ${SHELL_CAPTURE_TASK_HARD_CAP_MS}ms hard cap; ` +
209
+ `a handler is likely wedged on a never-settling await`,
210
+ );
224
211
  }
225
212
 
226
213
  /**
@@ -240,6 +227,13 @@ function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
240
227
  * keeps a late-settling stale task from releasing its replacement's entry.
241
228
  */
242
229
  interface CaptureGuardToken {
230
+ /**
231
+ * The schedule time while the capture waits in the queue (which drops it at
232
+ * CAPTURE_QUEUE_WAIT_BUDGET_MS, under the cap), then the time its hard cap
233
+ * started. Stamped only at schedule time, a capture that had waited in the
234
+ * queue read as older than its cap while still inside it, and the next
235
+ * request scheduled a duplicate capture of the key.
236
+ */
243
237
  startedAt: number;
244
238
  }
245
239
 
@@ -313,7 +307,7 @@ function refusedCaptureCeilingMs(): number {
313
307
  */
314
308
  const refusedCaptures = new Map<string, { failures: number; until: number }>();
315
309
 
316
- /** True iff `key` is still inside its (exponential) backoff window. */
310
+ /** A bake-lane container that settled with redirect() or notFound(). */
317
311
  function isSettledLoaderSignal(value: unknown): boolean {
318
312
  return (
319
313
  isLoaderDataResult(value) &&
@@ -322,6 +316,7 @@ function isSettledLoaderSignal(value: unknown): boolean {
322
316
  );
323
317
  }
324
318
 
319
+ /** True iff `key` is still inside its (exponential) backoff window. */
325
320
  function isCaptureBackedOff(key: string): boolean {
326
321
  const entry = refusedCaptures.get(key);
327
322
  if (entry === undefined) return false;
@@ -353,14 +348,10 @@ function clearCaptureBackoff(key: string): void {
353
348
  }
354
349
 
355
350
  /**
356
- * Keys already warned about a refused (null) capture, so the eternal-MISS shape
357
- * logs once per key per isolate instead of on every request.
358
- */
359
- const warnedNullCaptures = new Set<string>();
360
-
361
- /**
362
- * Warn once per key that a capture produced no usable shell EVEN AFTER the
363
- * in-place retry (runShellCapture attempt 2). Naming both causes with the
351
+ * Warn once per key that a capture produced no usable shell: after the
352
+ * in-place retry (runShellCapture attempt 2), or after one attempt that ran
353
+ * out of its deadline (no retry: CaptureAttemptStats.noShellCause), with the
354
+ * terminal attempt's cause and pending component stacks. Naming both causes with the
364
355
  * distinguishing signal — does the route ever flip to HIT — is the whole point:
365
356
  * the pre-retry version blamed "a loader route without loading()" unconditionally
366
357
  * and misled users whose route DID have loading() and was merely cold. Because the
@@ -371,27 +362,57 @@ const warnedNullCaptures = new Set<string>();
371
362
  * The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
372
363
  * tarball, so the path resolves for consumers (a05c8251 convention).
373
364
  */
374
- function warnNullCaptureOnce(key: string): void {
375
- if (warnedNullCaptures.has(key)) return;
376
- warnedNullCaptures.add(key);
377
- console.warn(
378
- `[rango] Shell capture for "${key}" produced no usable shell after an in-place ` +
379
- "retry; nothing was stored, so this request stays on MISS. Causes, told apart " +
380
- "by whether the route ever flips to HIT:\n" +
381
- " 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
382
- "an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
383
- "request warms the modules.\n" +
384
- " 2. Something suspends above <body> with no Suspense boundary and never settles " +
385
- "within the capture window: a live loader (no ssr: false) read without loading() or " +
386
- "an inline <Suspense>; a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
387
- "slower than ppr.captureTimeout; or a pending promise with no Suspense above it. The " +
388
- "boundary belongs to the entry or component that owns the data.\n" +
365
+ function warnNullCaptureOnce(
366
+ key: string,
367
+ retried: boolean,
368
+ attempt: CaptureAttemptStats,
369
+ ): void {
370
+ const cause = attempt.noShellCause;
371
+ const pendingStacks = attempt.pendingStacks;
372
+ warnOnce(
373
+ "capture-no-shell",
374
+ key,
375
+ () =>
376
+ `[rango] Shell capture for "${key}" produced no usable shell` +
377
+ (retried ? " after an in-place retry" : "") +
378
+ "; nothing was stored, so this request stays on MISS. " +
379
+ (cause
380
+ ? `Cause: ${cause}. Every capture waits for the handler layer to finish and ` +
381
+ 'bakes it; make the slow part cheaper (cache() or "use cache"), raise ' +
382
+ "ppr.captureTimeout, or move it into a live loader (no ssr: false) read under " +
383
+ "loading() or an inline <Suspense>.\n"
384
+ : "Causes, told apart by whether the route ever flips to HIT:\n" +
385
+ " 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
386
+ "an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
387
+ "request warms the modules.\n" +
388
+ " 2. Something suspends above <body> with no Suspense boundary and never settles " +
389
+ "within the capture window: a live loader (no ssr: false) read without loading() or " +
390
+ "an inline <Suspense>, or a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
391
+ "slower than ppr.captureTimeout. The boundary belongs to the entry or component " +
392
+ "that owns the data.\n") +
393
+ (pendingStacks && pendingStacks.length > 0
394
+ ? "Components still pending when the capture froze the shell (one of them " +
395
+ "suspended above <body>):\n" +
396
+ pendingStacks.map(formatPendingStack).join("\n") +
397
+ "\n"
398
+ : "") +
389
399
  PPR_LANE_HINT,
390
400
  );
391
401
  }
392
402
 
393
- /** Keys already warned about a deterministic capture refusal (once per key). */
394
- const warnedRefusedCaptures = new Set<string>();
403
+ /** Stacks kept per attempt: the root pin is almost always among the first. */
404
+ const MAX_PENDING_STACKS = 3;
405
+
406
+ /** The first lines of one component stack, indented under the warning. */
407
+ function formatPendingStack(stack: string): string {
408
+ return stack
409
+ .split("\n")
410
+ .map((line) => line.trim())
411
+ .filter(Boolean)
412
+ .slice(0, 8)
413
+ .map((line) => ` ${line}`)
414
+ .join("\n");
415
+ }
395
416
 
396
417
  /**
397
418
  * Warn once per key that the capture was REFUSED for a deterministic reason
@@ -406,15 +427,81 @@ function warnCaptureRefusedOnce(
406
427
  reason: string,
407
428
  pointer: string = "See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
408
429
  ): void {
409
- if (warnedRefusedCaptures.has(key)) return;
410
- warnedRefusedCaptures.add(key);
411
- console.warn(
412
- `[rango] Shell capture for "${key}" was refused: ${reason}\n` +
430
+ warnOnce(
431
+ "capture-refused",
432
+ key,
433
+ () =>
434
+ `[rango] Shell capture for "${key}" was refused: ${reason}\n` +
413
435
  "The route stays on MISS (axis 1) — the page keeps working, only the shell " +
414
436
  `cache is off. ${pointer}`,
415
437
  );
416
438
  }
417
439
 
440
+ /**
441
+ * Refuse the capture when a capture guard tripped: a request-scoped read
442
+ * (cookies(), headers(), a { cache: false } variable) or ctx.dynamic() made
443
+ * anywhere the capture waited for — a handler, a promise it passes or pushes,
444
+ * an async server component, a bake-lane loader. The read throws where it
445
+ * happens, but the code can catch that throw, so the flag the guard sets on
446
+ * the capture context is what decides (server/context.ts
447
+ * guardIdentityRead). Returns true when it refused (and warned).
448
+ */
449
+ function refuseOnCaptureGuard(
450
+ key: string,
451
+ derivedCtx: RequestContext<any>,
452
+ ): boolean {
453
+ if (derivedCtx._dynamic) {
454
+ // A build-time capture reports dynamic() as its own outcome
455
+ // (build-shell-capture.ts): the URL keeps runtime capture.
456
+ if (derivedCtx.build) return true;
457
+ warnCaptureRefusedOnce(
458
+ key,
459
+ "ctx.dynamic() was called while capturing the shared shell (in a handler, or " +
460
+ "in a promise or async component the capture waited for). A dynamic render " +
461
+ "has no shell; the route keeps serving axis 1 for requests that opt out.",
462
+ );
463
+ return true;
464
+ }
465
+ const trip = derivedCtx._shellCaptureGuardTripped;
466
+ if (!trip) return false;
467
+ // Name the recorded source instead of hardcoding a lane: a trip can come
468
+ // from a bake-lane SEGMENT loader or from handler/render code, including a
469
+ // loader a handler awaits (issue #672). The guard recorded the read and its
470
+ // fix (server/context.ts guardIdentityRead): cookies()/headers(), a
471
+ // { cache: false } variable, and the theme reads (#971).
472
+ const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
473
+ const origin = loaderId
474
+ ? `the loader "${loaderId}"`
475
+ : "handler/render code (no loader body was executing)";
476
+ warnCaptureRefusedOnce(
477
+ key,
478
+ `${origin} read ${trip.surface} during capture; request-scoped data must not ` +
479
+ `bake into the shared shell. ${trip.fix}`,
480
+ PPR_LANE_HINT,
481
+ );
482
+ return true;
483
+ }
484
+
485
+ /** The no-shell cause for handler output that missed the capture deadline. */
486
+ const HANDLER_OUTPUT_TIMEOUT_REASON: string =
487
+ "the handler output (a promise it passes or pushes, or an async server " +
488
+ "component) did not settle within ppr.captureTimeout";
489
+
490
+ /** The refusal for a capture that stored no doc record (a HIT needs it). */
491
+ const NO_DOC_RECORD_REASON: string =
492
+ "the capture produced no doc segment record, so a HIT could not replay the " +
493
+ "handler layer. Either the route's cache() refused the write (cache(false), or " +
494
+ "a condition() that returned false for the capture), or rendering the handler " +
495
+ "output failed (see the cache-write error reported for this key).";
496
+
497
+ /**
498
+ * Default limit (bytes) on a whole stored shell entry — prelude, postponed
499
+ * state, and snapshot — when the store declares none
500
+ * (SegmentCacheStore.maxShellEntryBytes). Cloudflare KV's 25 MiB value limit:
501
+ * the tightest common store tier a shell entry lands in.
502
+ */
503
+ export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
504
+
418
505
  /**
419
506
  * Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
420
507
  * inside a shell entry, when the route's `ppr` option does not set
@@ -424,75 +511,75 @@ function warnCaptureRefusedOnce(
424
511
  * while still fitting any sane pinned-ring payload. Applied ONLY in
425
512
  * captureAndStoreShell (the single defaulting site — resolvePprConfig passes
426
513
  * the option through undefaulted), so every producer and direct caller gets
427
- * the same policy. Over the cap the snapshot is skipped (shell still stored;
428
- * pinned reads drift — see PartialPrerenderProps.maxSnapshotBytes).
514
+ * the same policy. The doc record is exempt; over the cap the other records
515
+ * are dropped (shell still stored and replayed; the pins' loaders read the
516
+ * live store — see PartialPrerenderProps.maxSnapshotBytes).
429
517
  */
430
518
  export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
431
519
 
432
520
  /** Cached encoder for the snapshot byte measurement (one per module, not per capture). */
433
521
  const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
434
522
 
435
- /** Keys already warned about an over-cap snapshot (once per key per isolate). */
436
- const warnedOverCapSnapshots = new Set<string>();
437
-
438
523
  /**
439
- * Warn once per key that the capture data snapshot exceeded the route's
440
- * `maxSnapshotBytes` cap and was skipped. The shell entry is still stored and
441
- * served — only the pinned-read replay is lost, so shell-baked cached content
442
- * can drift from the frozen prelude between capture and HIT and hydration
443
- * repairs it client-side (the pre-snapshot behavior). Once per key: the same
444
- * page recaptures on every TTL roll and would otherwise re-warn forever.
524
+ * Warn once per key that the capture's loader pins exceeded the route's
525
+ * `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
526
+ * with its doc record and every HIT replays the handler layer; only the
527
+ * pinned loaders lose their captured values, so their cached data can drift
528
+ * from the frozen prelude and hydration repairs it client-side. Once per
529
+ * key: the same page recaptures on every TTL roll and would otherwise
530
+ * re-warn forever.
445
531
  */
446
532
  function warnSnapshotOverCapOnce(
447
533
  key: string,
448
534
  snapshotBytes: number,
449
535
  capBytes: number,
450
536
  ): void {
451
- if (warnedOverCapSnapshots.has(key)) return;
452
- warnedOverCapSnapshots.add(key);
453
- console.warn(
454
- `[rango] Shell capture for "${key}" recorded a ${snapshotBytes}-byte data ` +
455
- `snapshot, over the ${capBytes}-byte cap — the snapshot was skipped and ` +
456
- "the shell was stored without it. The page keeps serving, but cached " +
457
- "content baked into the shell is no longer pinned: if it drifts before " +
458
- "the shell's TTL, hydration repairs the mismatch client-side. Raise the " +
459
- "cap via the route's ppr option ({ maxSnapshotBytes }) if the entry " +
460
- "still fits your store's value limit (Cloudflare KV: 25 MiB per value), " +
461
- "or shrink the cache()'d data the shell bakes.",
537
+ warnOnce(
538
+ "snapshot-over-cap",
539
+ key,
540
+ () =>
541
+ `[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
542
+ `loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
543
+ "the shell was stored with its doc record only. HITs still replay the " +
544
+ "handler layer, but ssr: false loaders now read the live store: if " +
545
+ "their data drifts before the shell's TTL, hydration repairs the " +
546
+ "mismatch client-side. Raise the cap via the route's ppr option " +
547
+ "({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
548
+ "(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
549
+ "loaders read.",
462
550
  );
463
551
  }
464
552
 
465
553
  /**
466
- * Dev warning threshold (ms) for the slowest bake-source settlement wait. Bake
467
- * time is by-design shell latency (top-level pushed handle promises and
468
- * bake-lane loader containers settle before the freeze), but it recurs on
469
- * EVERY capture of the route and occupies the per-isolate serialized capture
470
- * queue — silently: the served response's own timing never shows it (a route
471
- * can read 13ms TTFB while each of its captures holds the queue for seconds).
472
- * Past this threshold the source gets named once per key with the remedy
473
- * ladder.
554
+ * Dev warning threshold (ms) for the record-first settle
555
+ * (CaptureAttemptStats.recordSettleMs). Bake time is by-design shell latency
556
+ * (handler output, top-level pushed handle promises and bake-lane loader
557
+ * containers settle before the freeze), but it recurs on EVERY capture of the
558
+ * route and occupies the per-isolate serialized capture queue — silently: the
559
+ * served response's own timing never shows it (a route can read 13ms TTFB
560
+ * while each of its captures holds the queue for seconds). Past this
561
+ * threshold the cost gets named once per key with the remedy ladder.
474
562
  */
475
563
  const SHELL_CAPTURE_BAKE_WARN_MS = 2_000;
476
564
 
477
- /** Keys already warned about an expensive bake source (once per key). */
478
- const warnedBakeCosts = new Set<string>();
479
-
480
565
  /**
481
- * Dev-only, once per key: name the slowest bake source and the three remedies
482
- * in preference order. The doctrine ("nesting = liveness") makes the cost
483
- * legitimate, so this is a cost report with an exit, not a deprecation.
566
+ * Dev-only, once per key: name the bake cost and the three remedies in
567
+ * preference order. Handler output and bake-lane values are shell material
568
+ * by design, so this is a cost report with an exit, not a deprecation.
484
569
  */
485
570
  function warnBakeCostOnce(key: string, source: string, ms: number): void {
486
- if (warnedBakeCosts.has(key)) return;
487
- warnedBakeCosts.add(key);
488
- console.warn(
489
- `[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
571
+ warnOnce(
572
+ "bake-cost",
573
+ key,
574
+ () =>
575
+ `[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
490
576
  "settle before the shell could freeze. This cost recurs on every capture " +
491
577
  "of the route and occupies the per-isolate capture queue; the served " +
492
- "response never shows it. To make the value per-request, nest it " +
493
- "({ data: promise }) and read it with use() under Suspense; to keep it " +
494
- "baked but cheap, wrap the work in cache(); for a loader(Def, { ssr: false }), " +
495
- "drop ssr: false to move it to the live lane. " +
578
+ "response never shows it. To keep it baked but cheap, wrap the work in " +
579
+ 'cache() or "use cache"; to make it per-request, load it in a live ' +
580
+ "loader (no ssr: false) read under loading() or an inline <Suspense>; for " +
581
+ "a loader(Def, { ssr: false }), return the slow part as a nested promise " +
582
+ "or drop ssr: false. " +
496
583
  PPR_LANE_HINT,
497
584
  );
498
585
  }
@@ -509,10 +596,13 @@ export interface ShellCaptureDebugEvent {
509
596
  key: string;
510
597
  /**
511
598
  * What happened:
512
- * - stored / redirect / no-shell / refused: one capture ATTEMPT's outcome
513
- * (see CaptureAttemptOutcome for the semantics of each)
599
+ * - stored / redirect / no-shell / refused / expired: one capture
600
+ * ATTEMPT's outcome (see CaptureAttemptOutcome for the semantics of each)
514
601
  * - error: the capture task failed with a genuine error (also routed through
515
602
  * reportCacheError; the key is backed off)
603
+ * - skip-stored: a capture for the key stored after the scheduling request
604
+ * read the store and missed (ShellCaptureDescriptor.storedSeqAtRead), so
605
+ * the shell that request missed exists now
516
606
  * - skip-in-flight: scheduleShellCapture found a capture already running for
517
607
  * the key (stampede guard) and scheduled nothing
518
608
  * - skip-backoff: the key is inside its refused-capture backoff window and
@@ -535,8 +625,10 @@ export interface ShellCaptureDebugEvent {
535
625
  | "redirect"
536
626
  | "no-shell"
537
627
  | "refused"
628
+ | "expired"
538
629
  | "error"
539
630
  | "skip-in-flight"
631
+ | "skip-stored"
540
632
  | "skip-backoff"
541
633
  | "skip-capacity"
542
634
  | "skip-inert-store"
@@ -580,14 +672,13 @@ export interface ShellCaptureDebugEvent {
580
672
  */
581
673
  queueAhead?: number;
582
674
  /**
583
- * Ms the capture gate was HELD waiting for the slowest bake source — a
584
- * top-level pushed handle promise or a bake-lane loader container — to
585
- * settle, measured from capture start. Bake time is by-design shell latency
586
- * (the hole doctrine: top-level promises bake), but it recurs on EVERY
587
- * capture of the route and occupies the serialized queue; this field makes
588
- * it attributable. Absent when nothing held the gate.
675
+ * How long the capture waited for its doc record: the handler layer's
676
+ * promises, async server components and handle pushes settling, then the
677
+ * record's encode (settleCaptureRecord). Part of the one capture deadline.
589
678
  */
590
- bakeWaitMs?: number;
679
+ recordSettleMs?: number;
680
+ /** Bytes of the stored entry: prelude, postponed state, and snapshot. */
681
+ entryBytes?: number;
591
682
  }
592
683
 
593
684
  /**
@@ -643,8 +734,11 @@ export function describeShellCaptureEvent(
643
734
  if (event.queueAhead !== undefined) {
644
735
  parts.push(`queue-ahead=${event.queueAhead}`);
645
736
  }
646
- if (event.bakeWaitMs !== undefined) {
647
- parts.push(`bake=${event.bakeWaitMs}ms`);
737
+ if (event.recordSettleMs !== undefined) {
738
+ parts.push(`record=${event.recordSettleMs}ms`);
739
+ }
740
+ if (event.entryBytes !== undefined) {
741
+ parts.push(`entry=${event.entryBytes}b`);
648
742
  }
649
743
  return parts.join(" ");
650
744
  }
@@ -681,6 +775,7 @@ const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
681
775
  "redirect",
682
776
  "no-shell",
683
777
  "refused",
778
+ "expired",
684
779
  "error",
685
780
  ]);
686
781
 
@@ -786,22 +881,13 @@ export interface FlightCaptureGate {
786
881
  * closing (no holes) fires quiesce immediately for the DATA variant — the
787
882
  * TransformStream then closes the readable, so fizz completes with postponed null.
788
883
  *
789
- * `holdUntil` keeps the gate from FREEZING before shell material with real latency
790
- * has emitted. The hole doctrine bakes TOP-LEVEL pushed handle promises into the
791
- * shell (resolvedHandleStream awaits them before the handles row emits), but a
792
- * pushed promise that takes longer than the quiet window would otherwise be frozen
793
- * out — the handles row would never reach fizz and the prelude would come back
794
- * trivial. While `holdUntil` is pending, byte-quiet detection keeps running but the
795
- * gate neither fires nor freezes; once it resolves, the quiet counter restarts so a
796
- * burst of rows unblocked by it (the resolved handles row) is still captured. It
797
- * never delays a HOLE from postponing: holes are pending promises that emit no
798
- * bytes, so holding the gate open longer only ever admits shell rows. Bounded by
799
- * captureShellHTML's maxWaitMs like every other quiesce input.
884
+ * Nothing the shell bakes can still be pending here: settleCaptureRecord already
885
+ * waited for the top-level handle promises and the bake-lane loader containers
886
+ * before the capture's Flight render began, so the handles row and the baked
887
+ * loader rows emit at once and only the holes stay silent.
800
888
  */
801
889
  export function gateFlightForCapture(
802
890
  source: ReadableStream<Uint8Array>,
803
- quietHops: number = FLIGHT_QUIET_HOPS,
804
- holdUntil?: Promise<unknown>,
805
891
  ): FlightCaptureGate {
806
892
  let resolveQuiet!: () => void;
807
893
  const quiesce = new Promise<void>((resolve) => {
@@ -813,32 +899,9 @@ export function gateFlightForCapture(
813
899
  let settled = false;
814
900
  let disposed = false;
815
901
  let frozen = false;
816
- let held = holdUntil !== undefined;
817
- let heldFirePending = false;
818
-
819
- if (holdUntil !== undefined) {
820
- const release = (): void => {
821
- held = false;
822
- if (heldFirePending && !settled && !disposed) {
823
- // Quiet elapsed while held: restart the quiet count instead of firing
824
- // immediately, so rows unblocked by the hold (the baked handles row)
825
- // still flow before the freeze.
826
- heldFirePending = false;
827
- armed = false;
828
- arm();
829
- }
830
- };
831
- // Resolve OR reject releases the hold (a rejected handle value is dropped by
832
- // resolveDeferredHandleValues; the capture must not hang on it).
833
- holdUntil.then(release, release);
834
- }
835
902
 
836
903
  const fire = (): void => {
837
904
  if (settled) return;
838
- if (held) {
839
- heldFirePending = true;
840
- return;
841
- }
842
905
  settled = true;
843
906
  frozen = true;
844
907
  resolveQuiet();
@@ -862,7 +925,7 @@ export function gateFlightForCapture(
862
925
  if (settled || disposed) return;
863
926
  if (bytesSeen === lastSeen) {
864
927
  quiet += 1;
865
- if (quiet >= quietHops) {
928
+ if (quiet >= FLIGHT_QUIET_HOPS) {
866
929
  fire();
867
930
  return;
868
931
  }
@@ -948,16 +1011,13 @@ export interface ShellCaptureDescriptor {
948
1011
  tags?: string[];
949
1012
  /**
950
1013
  * Per-route capture settle budget in ms (`ppr.captureTimeout`, resolved by
951
- * resolvePprConfig). Feeds captureShellHTML's maxWaitMs — the ONE deadline
952
- * bounding the whole capture, so it covers BOTH the fizz prerender AND the
953
- * deferred-material settle window (the handlesBaked/loader-container
954
- * holdUntil that keeps the gate from freezing while top-level pushes are
955
- * pending). Undefined = SHELL_CAPTURE_MAX_WAIT_MS (15_000).
1014
+ * resolvePprConfig): the ONE deadline bounding the whole capture — the match,
1015
+ * the record-first settle (settleCaptureRecord), then the fizz prerender with
1016
+ * what is left (captureShellHTML's maxWaitMs). Undefined =
1017
+ * SHELL_CAPTURE_MAX_WAIT_MS (15_000).
956
1018
  */
957
1019
  captureTimeout?: number;
958
1020
  store?: SegmentCacheStore<any>;
959
- /** Gates the concise per-attempt capture breadcrumbs (INTERNAL_RANGO_DEBUG). */
960
- debug?: boolean;
961
1021
  /**
962
1022
  * Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
963
1023
  * the snapshot is skipped and the shell stored without it (reported once per
@@ -974,6 +1034,33 @@ export interface ShellCaptureDescriptor {
974
1034
  debugSink?: (event: ShellCaptureDebugEvent) => void;
975
1035
  /** Store the snapshot for navigation replay, never the captured HTML prelude. */
976
1036
  navigationOnly?: true;
1037
+ /**
1038
+ * lastStoredCaptureSeq(key) when a document request read the store and
1039
+ * missed. The request schedules its capture only after rendering the MISS,
1040
+ * so a capture another request started can store in between; a later
1041
+ * sequence number then skips the redundant one (`skip-stored`), which would
1042
+ * re-render the page and drop the isolate's memo of the shell just stored.
1043
+ */
1044
+ storedSeqAtRead?: number;
1045
+ }
1046
+
1047
+ /** Per key, the sequence number of its latest stored capture (bounded). */
1048
+ const storedCaptureSeqs = new Map<string, number>();
1049
+ const STORED_CAPTURE_SEQS_MAX = 1_000;
1050
+ let storedCaptureSeq = 0;
1051
+
1052
+ /** The latest stored capture's sequence number for `key` in this isolate, or 0. */
1053
+ export function lastStoredCaptureSeq(key: string): number {
1054
+ return storedCaptureSeqs.get(key) ?? 0;
1055
+ }
1056
+
1057
+ function noteCaptureStored(key: string): void {
1058
+ storedCaptureSeqs.delete(key);
1059
+ if (storedCaptureSeqs.size >= STORED_CAPTURE_SEQS_MAX) {
1060
+ const oldest = storedCaptureSeqs.keys().next().value;
1061
+ if (oldest !== undefined) storedCaptureSeqs.delete(oldest);
1062
+ }
1063
+ storedCaptureSeqs.set(key, ++storedCaptureSeq);
977
1064
  }
978
1065
 
979
1066
  /**
@@ -1011,6 +1098,13 @@ export function scheduleShellCapture(
1011
1098
  // new token, and the stale task's token-guarded release can no longer
1012
1099
  // touch it.
1013
1100
  }
1101
+ if (
1102
+ descriptor.storedSeqAtRead !== undefined &&
1103
+ lastStoredCaptureSeq(key) > descriptor.storedSeqAtRead
1104
+ ) {
1105
+ publishCaptureDebugEvent(descriptor, { key, outcome: "skip-stored" });
1106
+ return;
1107
+ }
1014
1108
  // Refused/failed within the window → skip the doomed re-render (one probe per
1015
1109
  // key per window per isolate). Expired entries self-evict inside the check.
1016
1110
  if (isCaptureBackedOff(key)) {
@@ -1035,16 +1129,23 @@ export function scheduleShellCapture(
1035
1129
  const guardToken: CaptureGuardToken = { startedAt: Date.now() };
1036
1130
  inFlightCaptures.set(key, guardToken);
1037
1131
  const captureTask = async (span: TraceSpan) => {
1132
+ // The guard's age counts from the task's start, not its scheduling: a
1133
+ // capture queued behind others (up to CAPTURE_QUEUE_WAIT_BUDGET_MS) is
1134
+ // not abandoned while its SSR setup loads.
1135
+ guardToken.startedAt = Date.now();
1038
1136
  try {
1039
- const setupUrl = descriptor.navigationOnly
1137
+ // A navigation-only capture renders under document request identity:
1138
+ // its transport parameters and headers are stripped once, here, for the
1139
+ // SSR setup and every attempt.
1140
+ const captureUrl = descriptor.navigationOnly
1040
1141
  ? stripInternalParams(url)
1041
1142
  : url;
1042
- const setupRequest = descriptor.navigationOnly
1043
- ? createNavigationCaptureRequest(request, setupUrl)
1143
+ const captureRequest = descriptor.navigationOnly
1144
+ ? createNavigationCaptureRequest(request, captureUrl)
1044
1145
  : request;
1045
1146
  const resolvedSsrModule =
1046
1147
  typeof ssrModule === "function"
1047
- ? await ssrModule(setupRequest, setupUrl)
1148
+ ? await ssrModule(captureRequest, captureUrl)
1048
1149
  : ssrModule;
1049
1150
  if (
1050
1151
  !resolvedSsrModule ||
@@ -1053,19 +1154,21 @@ export function scheduleShellCapture(
1053
1154
  ) {
1054
1155
  return;
1055
1156
  }
1056
- // Hard-capped: SHELL_CAPTURE_MAX_WAIT_MS arms only inside
1057
- // captureShellHTML, AFTER the capture's router.match() — a handler
1058
- // wedged on a never-settling upstream await has no deadline in force
1059
- // and would strand the stampede guard and the queue slot (production
1060
- // pilot). The cap rejects, riding the existing catch: backoff +
1061
- // reportCacheError + token-guarded release. The abandoned attempt keeps
1062
- // running until its context dies; nothing awaits it.
1157
+ // Hard-capped: ppr.captureTimeout bounds the attempt, but a match that
1158
+ // loses its deadline keeps running (no abort signal), and a handler
1159
+ // wedged on a never-settling upstream await would strand the stampede
1160
+ // guard and the queue slot (production pilot). The cap rejects, riding
1161
+ // the existing catch: backoff + reportCacheError + token-guarded
1162
+ // release. The abandoned attempt keeps running until its context dies;
1163
+ // nothing awaits it. From here the guard's age counts from the cap's
1164
+ // start, so a slow SSR setup does not shorten the capped run.
1165
+ guardToken.startedAt = Date.now();
1063
1166
  const outcome = await raceTaskHardCap(
1064
1167
  runShellCapture(
1065
1168
  ctx,
1066
- request,
1169
+ captureRequest,
1067
1170
  env,
1068
- url,
1171
+ captureUrl,
1069
1172
  reqCtx,
1070
1173
  resolvedSsrModule,
1071
1174
  descriptor,
@@ -1077,8 +1180,13 @@ export function scheduleShellCapture(
1077
1180
  // any prior backoff; a `no-shell` (after the in-place retry) backs the key
1078
1181
  // off so the next requests don't re-probe it. A `redirect` has no shell but
1079
1182
  // is not a doomed render — leave the backoff untouched.
1080
- if (outcome === "stored") clearCaptureBackoff(key);
1081
- else if (outcome === "no-shell") {
1183
+ if (outcome === "stored") {
1184
+ clearCaptureBackoff(key);
1185
+ // Only a document entry is the shell a document MISS read the store
1186
+ // for: document serving reads a navigation-only entry as a MISS, even
1187
+ // one a corrupt-snapshot heal stored under the document key.
1188
+ if (!descriptor.navigationOnly) noteCaptureStored(key);
1189
+ } else if (outcome === "no-shell") {
1082
1190
  markCaptureBackoff(key);
1083
1191
  publishCaptureDebugEvent(descriptor, {
1084
1192
  key,
@@ -1191,6 +1299,81 @@ export function scheduleShellCapture(
1191
1299
  runBackground(reqCtx, serializedTask);
1192
1300
  }
1193
1301
 
1302
+ /**
1303
+ * The ttl/swr a shell is stored with: the route's ppr policy, capped by the
1304
+ * route cache() records the capture replayed or wrote
1305
+ * (RequestContext._routeRecordWindow). A HIT replays the capture's handler
1306
+ * output, while a client navigation reads the route's cache() tier first, so
1307
+ * a shell that outlived its record would show a document request other
1308
+ * handler output than a navigation of the same URL. Freshness and total
1309
+ * lifetime are capped separately, like the record's own SWR: the shell is
1310
+ * fresh while the record is (at most `ttl`), and servable while the record
1311
+ * is (at most `ttl + swr`), so a record already inside its swr window yields
1312
+ * a shell that is stale from the start and serves while it recaptures.
1313
+ *
1314
+ * Whole seconds, rounded up (the SegmentCacheStore.putShell contract), so the
1315
+ * shell may outlast the record by under a second. Null when the record's
1316
+ * total lifetime has run out: the shell would be dead on arrival.
1317
+ */
1318
+ export function capShellWindow(
1319
+ ttl: number,
1320
+ swr: number,
1321
+ record: Pick<RouteRecordWindow, "freshUntil" | "staleUntil">,
1322
+ now: number,
1323
+ ): { ttl: number; swr: number } | null {
1324
+ const total = Math.min(ttl + swr, (record.staleUntil - now) / 1000);
1325
+ if (!(total > 0)) return null;
1326
+ // At most `total`: freshUntil <= staleUntil and swr >= 0.
1327
+ const fresh = Math.max(0, Math.min(ttl, (record.freshUntil - now) / 1000));
1328
+ const wholeTtl = Math.ceil(fresh);
1329
+ return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
1330
+ }
1331
+
1332
+ /**
1333
+ * Warn once per route (dev and production) that no shell was stored because
1334
+ * the route cache() entry ran out before the capture could store one
1335
+ * (capShellWindow returned null) where a retry cannot help: the capture
1336
+ * wrote the entry itself (`written`), or the in-place retry's entry ran out
1337
+ * too, or no budget was left to retry. The key backs off like a refused
1338
+ * capture.
1339
+ */
1340
+ function warnRecordRanOutOnce(
1341
+ route: string,
1342
+ record: RouteRecordWindow,
1343
+ detail: string,
1344
+ ): void {
1345
+ warnOnce(
1346
+ "record-ran-out",
1347
+ route,
1348
+ () =>
1349
+ `[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
1350
+ `${record.swr}) ran out before the shell capture could store a shell ` +
1351
+ `(${detail}), so no shell is stored and the route renders without ` +
1352
+ "one. A shell never outlives the route cache() entry it replays: give " +
1353
+ "that cache() a ttl or swr longer than the capture takes, queue wait " +
1354
+ "included.",
1355
+ );
1356
+ }
1357
+
1358
+ /**
1359
+ * A terminal `expired`: warn once per route with the record the attempt ran
1360
+ * out on, and return `no-shell` so the caller backs the key off.
1361
+ */
1362
+ function expiredTerminal(
1363
+ attempt: CaptureAttemptStats,
1364
+ reason: string,
1365
+ ): "no-shell" {
1366
+ const expired = attempt.expired;
1367
+ if (expired) {
1368
+ warnRecordRanOutOnce(
1369
+ expired.route,
1370
+ expired.record,
1371
+ `${reason}; the last attempt took ${expired.captureMs} ms`,
1372
+ );
1373
+ }
1374
+ return "no-shell";
1375
+ }
1376
+
1194
1377
  /**
1195
1378
  * The outcome of one capture attempt.
1196
1379
  * - `stored`: a usable shell was captured (and a putShell was attempted; a store
@@ -1198,13 +1381,30 @@ export function scheduleShellCapture(
1198
1381
  * the capture itself worked).
1199
1382
  * - `redirect`: the matched route redirects, so there is no shell to capture.
1200
1383
  * - `no-shell`: captureShellHTML returned null because the prelude was unusable
1201
- * or its private capture abort landed before the shell completed. This is the
1202
- * only RETRYABLE outcome.
1384
+ * or its private capture abort landed before the shell completed, or the
1385
+ * capture ran out of ppr.captureTimeout. Retried once in place, when no
1386
+ * deadline ran out (CaptureAttemptStats.noShellCause).
1387
+ * - `expired`: the route cache() record the capture read (not wrote) ran out
1388
+ * before the store (capShellWindow null): it was near its end (its age,
1389
+ * queue wait). Retried once in place, where a fresh match reads or renders
1390
+ * a newer record; an `expired` retry, or no hard-cap budget left for the
1391
+ * retry, is terminal `no-shell` (backoff, warned once per route).
1392
+ */
1393
+ type CaptureAttemptOutcome =
1394
+ | "stored"
1395
+ | "redirect"
1396
+ | "no-shell"
1397
+ | "refused"
1398
+ | "expired";
1399
+
1400
+ /**
1401
+ * What a capture task ends with (runShellCapture): a refusal and a terminal
1402
+ * `expired` are `no-shell`, which the scheduler backs off.
1203
1403
  */
1204
- type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
1404
+ type CaptureTaskOutcome = "stored" | "redirect" | "no-shell";
1205
1405
 
1206
1406
  function createNavigationCaptureRequest(request: Request, url: URL): Request {
1207
- const headers = new Headers(request.headers);
1407
+ const headers = new Headers(requestHeaders(request));
1208
1408
  headers.set("accept", "text/html");
1209
1409
  for (const name of [
1210
1410
  "rsc-action",
@@ -1220,11 +1420,12 @@ function createNavigationCaptureRequest(request: Request, url: URL): Request {
1220
1420
  }
1221
1421
 
1222
1422
  /**
1223
- * Per-attempt observability fields, filled along the capture path (barrier in
1224
- * attemptCapture, the rest in captureAndStoreShell) and folded into the
1225
- * attempt's {@link ShellCaptureDebugEvent} by runShellCapture. A plain mutable
1226
- * bag, not a return value: captureAndStoreShell's outcome type stays a string
1227
- * union its existing callers (producer B, tests) consume unchanged.
1423
+ * One attempt's record, filled along the capture path (barrier and deadline
1424
+ * causes in attemptCapture, the rest in captureAndStoreShell). runShellCapture
1425
+ * folds the observability fields into the attempt's
1426
+ * {@link ShellCaptureDebugEvent} and reads the rest to decide the retry and
1427
+ * the warning. A plain mutable bag, not a return value: captureAndStoreShell's
1428
+ * outcome type stays a string union producer B consumes unchanged.
1228
1429
  */
1229
1430
  type CaptureAttemptStats = Pick<
1230
1431
  ShellCaptureDebugEvent,
@@ -1235,8 +1436,24 @@ type CaptureAttemptStats = Pick<
1235
1436
  | "snapshotSkipped"
1236
1437
  | "untaggedBake"
1237
1438
  | "storeWrite"
1238
- | "bakeWaitMs"
1239
- >;
1439
+ | "recordSettleMs"
1440
+ | "entryBytes"
1441
+ > & {
1442
+ /**
1443
+ * Why a no-shell attempt ran out of ppr.captureTimeout (the match, the
1444
+ * handler output, or the prerender after them). A set cause skips the
1445
+ * in-place retry: the attempt's match may still be running.
1446
+ */
1447
+ noShellCause?: string;
1448
+ /**
1449
+ * Dev only: component stacks of the tasks still pending at a no-shell
1450
+ * attempt's abort, reported through prerender's onError (captureShellHTML's
1451
+ * onAbortedTask).
1452
+ */
1453
+ pendingStacks?: string[];
1454
+ /** The route cache() record an `expired` attempt ran out on. */
1455
+ expired?: { route: string; record: RouteRecordWindow; captureMs: number };
1456
+ };
1240
1457
 
1241
1458
  /**
1242
1459
  * Run the shell capture with a single in-place retry, then store the result.
@@ -1249,12 +1466,15 @@ type CaptureAttemptStats = Pick<
1249
1466
  * when we froze the shell; the attempt itself warmed the module graph, so a second
1250
1467
  * attempt a short beat later usually completes the shell in the SAME background
1251
1468
  * task. That kills the old multi-request warmup where the caller had to re-issue
1252
- * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`; a
1253
- * genuine render error is NOT retried — it propagates to scheduleShellCapture's
1254
- * reportCacheError. See docs/design/ppr-shell-resume.md.
1469
+ * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
1470
+ * that did not run out of ppr.captureTimeout (CaptureAttemptStats.noShellCause),
1471
+ * and on `expired`; a genuine render error is NOT retried — it propagates to
1472
+ * scheduleShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
1255
1473
  *
1256
- * `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
1257
- * drive the retry without a real 400ms wall-clock wait.
1474
+ * `request` and `url` are the capture's own identity (scheduleShellCapture
1475
+ * strips a navigation-only capture's transport parameters). `retryDelayMs` is
1476
+ * a parameter (defaulting to the module const) so unit tests can drive the
1477
+ * retry without a real 400ms wall-clock wait.
1258
1478
  */
1259
1479
  async function runShellCapture(
1260
1480
  ctx: HandlerContext<any>,
@@ -1265,14 +1485,8 @@ async function runShellCapture(
1265
1485
  ssrModule: SSRModule,
1266
1486
  descriptor: ShellCaptureDescriptor,
1267
1487
  retryDelayMs: number = SHELL_CAPTURE_RETRY_DELAY_MS,
1268
- ): Promise<CaptureAttemptOutcome> {
1269
- const captureUrl = descriptor.navigationOnly ? stripInternalParams(url) : url;
1270
- const captureRequest = descriptor.navigationOnly
1271
- ? createNavigationCaptureRequest(request, captureUrl)
1272
- : request;
1273
- const log = descriptor.debug
1274
- ? (message: string) => console.log(message)
1275
- : () => {};
1488
+ ): Promise<CaptureTaskOutcome> {
1489
+ const key = descriptor.key;
1276
1490
 
1277
1491
  // One attempt + its structured debug event: the stats object rides through
1278
1492
  // attemptCapture/captureAndStoreShell collecting the observability fields
@@ -1281,49 +1495,82 @@ async function runShellCapture(
1281
1495
  // event — scheduleShellCapture's catch publishes the terminal `error` event.
1282
1496
  const timedAttempt = async (
1283
1497
  attempt: number,
1284
- ): Promise<CaptureAttemptOutcome> => {
1498
+ ): Promise<{
1499
+ outcome: CaptureAttemptOutcome;
1500
+ stats: CaptureAttemptStats;
1501
+ }> => {
1285
1502
  const stats: CaptureAttemptStats = {};
1286
1503
  const start = performance.now();
1287
1504
  const outcome = await attemptCapture(
1288
1505
  ctx,
1289
- captureRequest,
1506
+ request,
1290
1507
  env,
1291
- captureUrl,
1508
+ url,
1292
1509
  reqCtx,
1293
1510
  ssrModule,
1294
1511
  descriptor,
1295
1512
  stats,
1296
1513
  );
1514
+ const { noShellCause, pendingStacks, expired, ...eventFields } = stats;
1297
1515
  publishCaptureDebugEvent(descriptor, {
1298
- key: descriptor.key,
1516
+ key,
1299
1517
  outcome,
1300
1518
  attempt,
1301
1519
  attemptMs: Math.round(performance.now() - start),
1302
- ...stats,
1520
+ ...eventFields,
1303
1521
  });
1304
- return outcome;
1522
+ return { outcome, stats };
1305
1523
  };
1306
1524
 
1525
+ const taskStartedAt = performance.now();
1307
1526
  const first = await timedAttempt(1);
1308
1527
  // "refused" is deterministic (identity guard / rejected bake-lane loader —
1309
1528
  // its own warning already fired): no retry, and the caller backs the key off
1310
1529
  // exactly like a structural no-shell.
1311
- if (first === "refused") return "no-shell";
1312
- // "stored" (success) or "redirect" (no shell exists): nothing to retry.
1313
- if (first !== "no-shell") return first;
1314
-
1315
- // Attempt 1 produced no usable shell. Retry ONCE in place — the first attempt
1316
- // warmed the dev transform graph / cold worker, so attempt 2 typically completes
1317
- // the shell without another HTTP request. The concise line is gated on the
1318
- // middleware's debug flag (threaded via the descriptor) so it replaces the old
1319
- // full DOMException dump with one readable breadcrumb.
1320
- log(
1321
- `[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
1322
- );
1323
- await delay(retryDelayMs);
1530
+ if (first.outcome === "refused") return "no-shell";
1531
+ if (first.outcome === "stored" || first.outcome === "redirect") {
1532
+ return first.outcome;
1533
+ }
1534
+ if (first.outcome === "expired") {
1535
+ // The record attempt 1 read ran out mid-capture: a fresh match reads or
1536
+ // renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
1537
+ // so a retry that could not finish inside it (barrier + captureTimeout)
1538
+ // is not started.
1539
+ const retryBudgetMs =
1540
+ SHELL_CAPTURE_TASK_HARD_CAP_MS - (performance.now() - taskStartedAt);
1541
+ const retryNeedsMs =
1542
+ SHELL_CAPTURE_WRITE_BARRIER_MS +
1543
+ (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1544
+ if (retryBudgetMs < retryNeedsMs) {
1545
+ return expiredTerminal(first.stats, "no time was left to retry");
1546
+ }
1547
+ } else {
1548
+ // Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
1549
+ // handlers may still be running (a match is not cancellable), and a retry
1550
+ // would start the same work beside them. A cold-module abort, the retry's
1551
+ // reason, ends well inside the deadline.
1552
+ if (first.stats.noShellCause !== undefined) {
1553
+ warnNullCaptureOnce(key, false, first.stats);
1554
+ return "no-shell";
1555
+ }
1556
+ // Attempt 1 produced no usable shell. Retry ONCE in place — the first
1557
+ // attempt warmed the dev transform graph / cold worker, so attempt 2
1558
+ // typically completes the shell without another HTTP request.
1559
+ await delay(retryDelayMs);
1560
+ }
1561
+
1562
+ // The retry is the last attempt, so none of its outcomes is retried: an
1563
+ // `expired` retry is terminal like a no-shell one, not handed back to the
1564
+ // scheduler (which would neither back the key off nor warn).
1324
1565
  const second = await timedAttempt(2);
1325
- if (second === "refused") return "no-shell";
1326
- if (second !== "no-shell") return second;
1566
+ if (second.outcome === "expired") {
1567
+ return expiredTerminal(
1568
+ second.stats,
1569
+ first.outcome === "expired" ? "twice in a row" : "on the in-place retry",
1570
+ );
1571
+ }
1572
+ if (second.outcome === "refused") return "no-shell";
1573
+ if (second.outcome !== "no-shell") return second.outcome;
1327
1574
 
1328
1575
  // Both attempts came back with no usable shell. Cold-start would have healed by
1329
1576
  // now, so the eternal-MISS structural shape (a boundary-less live-loader read;
@@ -1331,23 +1578,177 @@ async function runShellCapture(
1331
1578
  // warn once per key. Ordering matters: because the retry
1332
1579
  // absorbs cold-start, cold-start routes almost never reach this warning. The
1333
1580
  // caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
1334
- log(
1335
- `[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
1336
- );
1337
- warnNullCaptureOnce(descriptor.key);
1581
+ warnNullCaptureOnce(key, true, second.stats);
1338
1582
  return "no-shell";
1339
1583
  }
1340
1584
 
1341
- /** Fold the capture's handle-liveness record into the entry flag (true | undefined). */
1342
- function handlerLayerIsLive(
1343
- liveness: RequestContext["_shellCaptureHandleLiveness"],
1344
- ): true | undefined {
1345
- if (!liveness) return undefined;
1346
- return liveness.holes ||
1347
- liveness.pendingPushes > 0 ||
1348
- liveness.handlerInvokedLoader
1349
- ? true
1350
- : undefined;
1585
+ /**
1586
+ * What the record-first step produced ({@link settleCaptureRecord}):
1587
+ * - `record`: the doc record settled; `match` carries its fragments in place
1588
+ * of the handler layer's elements, so the capture's Flight payload is the
1589
+ * bytes every HIT replays.
1590
+ * - `prerender`: the prerender store supplied the handler layer (a
1591
+ * Prerender route); its HIT tail replays the same build-time segments, so
1592
+ * the capture renders the match as is.
1593
+ * - `timeout`: handler output did not settle within the capture budget.
1594
+ * - `refused`: a deterministic refusal (a capture guard tripped, the route
1595
+ * opted out, or the handler output failed); the warning already fired.
1596
+ */
1597
+ export type CaptureRecordOutcome =
1598
+ | { kind: "record" | "prerender"; match: MatchResult }
1599
+ | { kind: "timeout"; reason: string }
1600
+ | { kind: "refused" };
1601
+
1602
+ /**
1603
+ * Record-first capture: the doc record is both the settle signal and the
1604
+ * parity source.
1605
+ *
1606
+ * Writing the record Flight-serializes every non-loader segment
1607
+ * (serializeSegments), which runs each handler's async server components and
1608
+ * waits for every promise the handler output carries — the capture's "is the
1609
+ * handler layer done" signal. Live-lane loader data is not in the record
1610
+ * (loader segments are excluded and their masked promises never settle), so
1611
+ * the wait never depends on a hole.
1612
+ *
1613
+ * The capture then renders its OWN Flight payload from the record's
1614
+ * fragments (fragmentSegments, the function a HIT tail uses) instead of from
1615
+ * the elements the match produced. Server components therefore render once
1616
+ * per capture, inside the record's encode: before this, the capture's Flight
1617
+ * render and the record's encode each ran them, so an uncached async
1618
+ * component showed one value in the prelude and another on every HIT.
1619
+ *
1620
+ * Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
1621
+ * top-level handle promises, the handler pushes' nested promises, and the
1622
+ * bake-lane loader containers settle first (the record encodes the handles
1623
+ * a HIT restores, and its handle encode has its own 5s timeout that must
1624
+ * never be the one that fires); then the capture's onResponse callbacks fire
1625
+ * with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
1626
+ * scope's own write), and the deferred writes settle.
1627
+ */
1628
+ export async function settleCaptureRecord(
1629
+ match: MatchResult,
1630
+ derivation: CaptureContextDerivation,
1631
+ capture: Pick<ShellCaptureDescriptor, "key">,
1632
+ deadline: number,
1633
+ ): Promise<CaptureRecordOutcome> {
1634
+ const { derivedCtx, freshHandleStore } = derivation;
1635
+ const recording = getRecordingStore(derivedCtx._cacheStore);
1636
+
1637
+ // Seal so handleStore.settled resolves once the tracked handlers settle:
1638
+ // cacheRoute waits for it, and so does the capture's quiesce. It gates only
1639
+ // on tracked handlers, not on deferred handle values
1640
+ // (ctx.use(Handle).defer()), so a defer whose resolver depends on a masked
1641
+ // loader never settles and the wait below runs into the deadline instead.
1642
+ freshHandleStore.seal();
1643
+ const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
1644
+ const settled = await raceDeadline(
1645
+ Promise.all([
1646
+ freshHandleStore.getData().then(resolveDeferredHandleValues),
1647
+ derivation.handlerPushesSettled(),
1648
+ loaderRecords && loaderRecords.size > 0
1649
+ ? Promise.allSettled([...loaderRecords.values()])
1650
+ : undefined,
1651
+ ]).catch(() => undefined),
1652
+ deadline,
1653
+ );
1654
+ // Past the deadline the record cannot settle in time: stop before its
1655
+ // write starts (no encode for a capture that already failed).
1656
+ if (!settled.done) {
1657
+ if (refuseOnCaptureGuard(capture.key, derivedCtx))
1658
+ return { kind: "refused" };
1659
+ return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1660
+ }
1661
+
1662
+ const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
1663
+ if (callbacks.length > 0) {
1664
+ const synthetic = new Response(null, { status: 200 });
1665
+ for (const cb of callbacks) {
1666
+ try {
1667
+ cb(synthetic);
1668
+ } catch {
1669
+ // A capture-time cache write that throws is degradation, not failure.
1670
+ }
1671
+ }
1672
+ }
1673
+ const drained = recording
1674
+ ? await recording.settleWrites(Math.max(0, deadline - Date.now()))
1675
+ : true;
1676
+
1677
+ // Capture guards first: a tripped guard usually also fails the record,
1678
+ // and its own message names the cause.
1679
+ if (refuseOnCaptureGuard(capture.key, derivedCtx)) return { kind: "refused" };
1680
+
1681
+ const docKey = derivedCtx._shellImplicitCache?.docKey;
1682
+ const record =
1683
+ recording && docKey !== undefined ? recording.getRecord(docKey) : undefined;
1684
+ if (
1685
+ !record ||
1686
+ !Array.isArray(record.segments) ||
1687
+ record.segments.length === 0
1688
+ ) {
1689
+ // Post-match serve-source truth (cache-lookup.ts tryPrerenderLookup).
1690
+ if (derivedCtx._pprReplayPostMatchReason === "prerender-store") {
1691
+ return { kind: "prerender", match };
1692
+ }
1693
+ if (!drained) {
1694
+ return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1695
+ }
1696
+ warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
1697
+ return { kind: "refused" };
1698
+ }
1699
+
1700
+ // A handle encode that timed out stores "" (handle-snapshot.ts
1701
+ // encodeHandleValue). With every handler push settled above, that only
1702
+ // happens for a promise the walk does not reach (inside a Map, Set, or class
1703
+ // instance), which never settles in time on a retry either.
1704
+ if (!record.handles && recordSegmentsHaveHandles(record, freshHandleStore)) {
1705
+ warnCaptureRefusedOnce(
1706
+ capture.key,
1707
+ "a handle value the handler pushed did not finish encoding into the doc " +
1708
+ "record. A promise inside a Map, Set, or class instance is not awaited " +
1709
+ "before the encode; push plain objects and arrays.",
1710
+ );
1711
+ return { kind: "refused" };
1712
+ }
1713
+
1714
+ const { fragmentSegments } = await import("../cache/segment-codec.js");
1715
+ const fragments = new Map(
1716
+ (await fragmentSegments(record.segments)).map((s) => [s.id, s]),
1717
+ );
1718
+ return {
1719
+ kind: "record",
1720
+ match: {
1721
+ ...match,
1722
+ segments: match.segments.map((segment) => {
1723
+ const fragment =
1724
+ segment.type === "loader" ? undefined : fragments.get(segment.id);
1725
+ return fragment
1726
+ ? {
1727
+ ...segment,
1728
+ component: fragment.component,
1729
+ layout: fragment.layout,
1730
+ loading: fragment.loading,
1731
+ }
1732
+ : segment;
1733
+ }),
1734
+ },
1735
+ };
1736
+ }
1737
+
1738
+ /** Whether the handle store holds handler pushes for the record's segments. */
1739
+ function recordSegmentsHaveHandles(
1740
+ record: CachedEntryData,
1741
+ handleStore: HandleStore,
1742
+ ): boolean {
1743
+ for (const segment of record.segments) {
1744
+ const id = segment?.metadata?.id;
1745
+ if (id === undefined) continue;
1746
+ const data = handleStore.getDataForSegment(id, true);
1747
+ for (const name in data) {
1748
+ if (data[name]!.length > 0) return true;
1749
+ }
1750
+ }
1751
+ return false;
1351
1752
  }
1352
1753
 
1353
1754
  /**
@@ -1365,8 +1766,8 @@ function handlerLayerIsLive(
1365
1766
  * - _requestTags: a fresh Set. The capture collects its OWN shell tags here —
1366
1767
  * non-loader tags only, since loaders are masked — which is exactly the tag
1367
1768
  * set a shell entry should be invalidatable by (loader tags belong to holes).
1368
- * - _transitionWhen: a fresh [] so the capture's transition gating is its own.
1369
1769
  * - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
1770
+ * - _dynamic / dynamic(): an own latch the capture reads.
1370
1771
  * - _metricsStore: undefined so the capture never appends to the foreground's
1371
1772
  * (already-finalized) metrics.
1372
1773
  * - _renderBarrier family: an own barrier wired to the fresh handle store
@@ -1379,6 +1780,8 @@ function handlerLayerIsLive(
1379
1780
  * capture guard is load-bearing). Middleware is NOT re-run: it already ran for the
1380
1781
  * triggering request, and the derived context inherits its post-middleware state
1381
1782
  * (guarding is serve-time; the shell is never served without the full chain).
1783
+ * After the match, {@link settleCaptureRecord} writes the doc record and the
1784
+ * capture renders from it.
1382
1785
  *
1383
1786
  * A FRESH context (and match/render) per attempt is what makes the retry sound:
1384
1787
  * the second attempt is a clean capture, not a resumption of the first.
@@ -1406,23 +1809,68 @@ async function attemptCapture(
1406
1809
  await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
1407
1810
  stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
1408
1811
 
1409
- const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(
1812
+ const derivation = deriveShellCaptureContext(
1410
1813
  reqCtx,
1411
1814
  descriptor,
1412
1815
  descriptor.navigationOnly ? { request, url } : undefined,
1413
1816
  );
1817
+ const { derivedCtx, freshHandleStore } = derivation;
1818
+ // The capture generation starts before matching or any snapshot read
1819
+ // (ShellCacheEntry.createdAt): a tag invalidated after it wins.
1414
1820
  const captureStartedAt = Date.now();
1415
1821
 
1416
1822
  return runWithRequestContext(derivedCtx, async () => {
1417
- const match = await ctx.router.match(request, { env });
1823
+ // One deadline for the whole capture (see
1824
+ // PartialPrerenderProps.captureTimeout): the match (handlers, and the
1825
+ // loaders they await), then the record, then the prerender with what is
1826
+ // left. A match that loses it keeps running (no abort signal), so the
1827
+ // attempt records a cause and runShellCapture does not retry it.
1828
+ const deadline =
1829
+ Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1830
+ const matched = await raceDeadline(
1831
+ ctx.router.match(request, { env }),
1832
+ deadline,
1833
+ );
1834
+ if (!matched.done) {
1835
+ stats.noShellCause =
1836
+ "the handlers (or a loader a handler awaits) did not return within " +
1837
+ "ppr.captureTimeout";
1838
+ return "no-shell";
1839
+ }
1840
+ const match = matched.value;
1418
1841
  // A route that redirects has no shell to capture — bail (no store write, no
1419
1842
  // retry: a redirect is deterministic).
1420
1843
  if (match.redirect) return "redirect";
1421
1844
 
1422
1845
  setRequestContextParams(match.params, match.routeName);
1423
1846
 
1424
- const payload = buildFullPayload(
1847
+ const settleStart = performance.now();
1848
+ const settled = await settleCaptureRecord(
1425
1849
  match,
1850
+ derivation,
1851
+ descriptor,
1852
+ deadline,
1853
+ );
1854
+ stats.recordSettleMs = Math.round(performance.now() - settleStart);
1855
+ if (
1856
+ process.env.NODE_ENV !== "production" &&
1857
+ settled.kind === "record" &&
1858
+ stats.recordSettleMs >= SHELL_CAPTURE_BAKE_WARN_MS
1859
+ ) {
1860
+ warnBakeCostOnce(
1861
+ descriptor.key,
1862
+ "the handler output (promises it passes or pushes, async server components)",
1863
+ stats.recordSettleMs,
1864
+ );
1865
+ }
1866
+ if (settled.kind === "refused") return "refused";
1867
+ if (settled.kind === "timeout") {
1868
+ stats.noShellCause = settled.reason;
1869
+ return "no-shell";
1870
+ }
1871
+
1872
+ const payload = buildFullPayload(
1873
+ settled.match,
1426
1874
  ctx,
1427
1875
  url,
1428
1876
  derivedCtx,
@@ -1446,15 +1894,23 @@ async function attemptCapture(
1446
1894
  // content (and tags propagated by async cache()/"use cache" reads) lands after
1447
1895
  // this synchronous construction point, so snapshotting here dropped it — the
1448
1896
  // shell-tag snapshot must sit behind the quiesce gate (issue #676).
1449
- return captureAndStoreShell(
1897
+ const outcome = await captureAndStoreShell(
1450
1898
  ssrModule,
1451
1899
  flightStage.stream,
1452
- freshHandleStore,
1453
1900
  derivedCtx,
1454
- descriptor,
1455
- stats,
1901
+ {
1902
+ ...descriptor,
1903
+ captureTimeout: Math.max(1, deadline - Date.now()),
1904
+ },
1456
1905
  captureStartedAt,
1906
+ stats,
1457
1907
  );
1908
+ if (outcome === "no-shell" && Date.now() >= deadline) {
1909
+ stats.noShellCause =
1910
+ "the shell render did not finish within what ppr.captureTimeout left " +
1911
+ `after the handler output settled (${stats.recordSettleMs}ms)`;
1912
+ }
1913
+ return outcome;
1458
1914
  });
1459
1915
  }
1460
1916
 
@@ -1463,13 +1919,20 @@ async function attemptCapture(
1463
1919
  * shared by BOTH shell producers: the runtime background capture
1464
1920
  * (attemptCapture, producer A) and the build-time prerender shell capture
1465
1921
  * (prerender/build-shell-capture.ts, producer B — issue #699). One
1466
- * implementation so the capture semantics — the nested-thenable mask funnel,
1467
- * handler-liveness bookkeeping, snapshot recording, the implicit doc-cache
1468
- * scope — cannot drift between producers.
1922
+ * implementation so the capture semantics — the handle push funnel (handler
1923
+ * pushes settled, loader pushes masked), the capture guards, snapshot
1924
+ * recording, the implicit doc-cache scope — cannot drift between producers.
1469
1925
  */
1470
1926
  export interface CaptureContextDerivation {
1471
1927
  derivedCtx: RequestContext;
1472
1928
  freshHandleStore: HandleStore;
1929
+ /**
1930
+ * Resolves once every handler handle push has settled at any depth of
1931
+ * plain objects and arrays (top-level promise, then the promises nested
1932
+ * in what it resolved to). The record-first step awaits it before the doc
1933
+ * record encodes its handles, so that encode never races a pending value.
1934
+ */
1935
+ handlerPushesSettled: () => Promise<void>;
1473
1936
  }
1474
1937
 
1475
1938
  /**
@@ -1487,52 +1950,75 @@ export function deriveShellCaptureContext(
1487
1950
  ): CaptureContextDerivation {
1488
1951
  const freshHandleStore = createHandleStore();
1489
1952
  freshHandleStore.onError = reqCtx._handleStore.onError;
1490
- // Shape = liveness for handles, exactly as for bake-lane loader containers
1491
- // (mask-nested.ts): nested thenables in a pushed handle container are
1492
- // per-request by declaration, so the CAPTURE's copy masks them — the
1493
- // consuming boundary postpones as a hole regardless of settle timing,
1494
- // instead of a fast-settling nested value baking into the shared shell. A
1495
- // TOP-LEVEL promise push keeps its documented bake contract (awaited
1496
- // pre-SSR, gate held open for it), but the container it RESOLVES to gets
1497
- // the same nested masking. Wrapping THIS store's push is the single funnel:
1498
- // the store exists only for this capture attempt, so every push wrapper
1499
- // (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
1500
- // the foreground store is untouched.
1501
- // Shell fast path bookkeeping on the same funnel:
1502
- // - handleLiveness: a nested thenable in a push made OUTSIDE a DSL loader
1503
- // scope (attribution read synchronously at push time — handler bodies,
1504
- // handler-invoked ctx.use(loader) callbacks, defers) declares
1505
- // handler-layer per-request data. Its mask is a hole only a handler
1506
- // re-run can fill, so the entry must not serve handler-free
1507
- // (ShellCacheEntry.handlerLiveHoles). Still-pending top-level handler
1508
- // pushes at the putShell barrier count too — their liveness is unknowable.
1509
- // - loader-push tag: DSL-loader pushes re-run fresh on every HIT, so their
1510
- // captured values must NOT enter a segment record's handle snapshot
1511
- // (replay would duplicate the fresh push, and their masked nested
1512
- // promises would stall the Flight handle encode to its timeout). The
1513
- // store tags them by array position (HandleStore.push loaderPush), which
1514
- // covers primitives, and only the captureHandles cache-write call site
1515
- // excludes tagged pushes — every other getDataForSegment consumer (the
1516
- // render-barrier snapshot, prerender) sees every push. Unflagged loaders
1517
- // never execute at capture; the tagged pushes come from loaders an
1518
- // ssr:false loader awaits via ctx.use and from loader-cache replays
1519
- // (loader-cache.ts replayLoaderHandles), both re-run on a HIT.
1520
- // EXCEPTION — bake-lane { ssr: false } loaders: they execute at capture
1521
- // (fresh.ts awaits them), their pushes are in the captured HTML (<head>
1522
- // title/meta, useHandle echoes), and the stored snapshot carries the same
1523
- // values so a HIT's handle data is guaranteed to match its own prelude.
1524
- // They still re-run on every HIT (resolveLoaderData, loader-cache.ts), so
1525
- // each kept push carries its loader as `owner` (HandleStore.push): the
1526
- // record stores it (CachedEntryData.handleOwners), the HIT restores it
1527
- // through pushReplayed, and the re-run's live pushes replace the
1528
- // restored copies in place. Only settled, thenable-free pushes
1529
- // qualify — a deferred (thenable) push or one with masked nested promises
1530
- // keeps the exclusion so the handle encode cannot stall on a
1531
- // never-resolving mask.
1532
- const handleLiveness = {
1533
- holes: false,
1534
- pendingPushes: 0,
1535
- handlerInvokedLoader: false,
1953
+ // The capture handle store's push is the single funnel every push wrapper
1954
+ // (setupLoaderAccess, createUseFunction, prerender) goes through; the store
1955
+ // exists only for this capture attempt, so the foreground store is
1956
+ // untouched. Two lanes:
1957
+ // - HANDLER pushes (outside a DSL loader scope: handler bodies, handler-
1958
+ // invoked ctx.use(loader) bodies, defers) are handler output, baked like
1959
+ // everything else the handler layer produces. They pass through
1960
+ // unmasked; settleHandlerPush tracks each one so the record-first step
1961
+ // (settleCaptureRecord) can wait for its promises, top-level and nested,
1962
+ // before the doc record encodes the handles a HIT replays. A route
1963
+ // cache() record's restore (handle-snapshot.ts restoreHandles) lands
1964
+ // here too: a value it restores through pushReplayed keeps that loader
1965
+ // as owner, so a HIT restores it as that loader's (pushRestored).
1966
+ // - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
1967
+ // loaders they await via ctx.use execute at capture (live loaders are
1968
+ // masked), their pushes are in the captured HTML (<head> title/meta,
1969
+ // useHandle echoes), and a HIT does not run a promise-free bake-lane
1970
+ // loader (loader-cache.ts), so the stored record carries them: each
1971
+ // settled, thenable-free push from a loader body keeps that loader as
1972
+ // `owner` (HandleStore.push), the record stores it
1973
+ // (CachedEntryData.handleOwners), and the HIT restores it through
1974
+ // pushRestored. Those copies stand: a promise-carrying bake-lane loader
1975
+ // that runs on the HIT reads the store, so its settled pushes (and those
1976
+ // replayed inside its body) are dropped and only its thenable ones are
1977
+ // added. A loader the route also runs on the live lane is a hole: its
1978
+ // live run replaces its copies (pushReplayed, #936), those of the
1979
+ // dependencies it awaits included (they are credited to it). Nested
1980
+ // thenables stay masked (the promise shape is the liveness
1981
+ // declaration, mask-nested.ts). A deferred (thenable) push,
1982
+ // one with masked nested promises, or one outside a loader body (a
1983
+ // loader-cache replay names its loader: pushReplayed below) stays
1984
+ // tagged (HandleStore.push loaderPush) so the
1985
+ // handle encode cannot stall on a never-resolving mask; only the
1986
+ // captureHandles cache-write call site excludes tagged pushes, and the
1987
+ // loader records then carry `runs: 1` so a HIT runs those bodies for
1988
+ // them.
1989
+ const handlerPushSettles: Promise<void>[] = [];
1990
+ // A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
1991
+ // route cache() record's owned values, restoreHandles) re-pushes a loader's
1992
+ // recorded values through pushReplayed, which calls push() outside any
1993
+ // loader body: name that loader for the funnel so the record keeps them
1994
+ // under it too.
1995
+ let replayOwner: string | undefined;
1996
+ // The live-lane loader whose body a push is made in, at any depth, unless
1997
+ // a bake-lane loader's body is nearer (createMatchContextForFull sets the
1998
+ // lanes before any loader runs).
1999
+ // A replay of a live-lane loader's push (a "use cache" or loader cache()
2000
+ // hit a recapture makes inside a bake-lane body) is that hole's too.
2001
+ const liveLaneOwner = (): string | undefined => {
2002
+ const lanes = derivedCtx._shellCaptureLoaderLanes;
2003
+ if (!lanes) return undefined;
2004
+ return (
2005
+ findEnclosingLoaderBody(
2006
+ (id) => lanes.get(id) === "live",
2007
+ (id) => lanes.get(id) === "bake",
2008
+ ) ??
2009
+ (replayOwner !== undefined && lanes.get(replayOwner) === "live"
2010
+ ? replayOwner
2011
+ : undefined)
2012
+ );
2013
+ };
2014
+ const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
2015
+ freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
2016
+ replayOwner = loaderId;
2017
+ try {
2018
+ rawPushReplayed(handleName, segmentId, value, loaderId);
2019
+ } finally {
2020
+ replayOwner = undefined;
2021
+ }
1536
2022
  };
1537
2023
  const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
1538
2024
  freshHandleStore.push = (
@@ -1540,49 +2026,63 @@ export function deriveShellCaptureContext(
1540
2026
  segmentId: string,
1541
2027
  value: unknown,
1542
2028
  ) => {
1543
- const pushedInLoaderScope = isInsideLoaderScope();
1544
- // Single walk: the mask reports whether it masked any nested thenable
1545
- // (the liveness declaration) while building the capture copy.
2029
+ if (!isInsideLoaderScope()) {
2030
+ handlerPushSettles.push(settleNestedThenables(value));
2031
+ rawCapturePush(handleName, segmentId, value, false, replayOwner);
2032
+ return;
2033
+ }
1546
2034
  let maskedNestedThenable = false;
1547
- const maskWithLiveness = (v: unknown): unknown => {
2035
+ const mask = (v: unknown): unknown => {
1548
2036
  const report: MaskReport = { thenable: false };
1549
2037
  const masked = maskNestedContainerThenables(v, undefined, report);
1550
- if (report.thenable) {
1551
- maskedNestedThenable = true;
1552
- if (!pushedInLoaderScope) handleLiveness.holes = true;
1553
- }
2038
+ if (report.thenable) maskedNestedThenable = true;
1554
2039
  return masked;
1555
2040
  };
1556
2041
  let masked: unknown;
1557
- let loaderPush = pushedInLoaderScope;
2042
+ let loaderPush = true;
1558
2043
  let owner: string | undefined;
1559
2044
  if (isThenable(value)) {
1560
- if (!pushedInLoaderScope) {
1561
- handleLiveness.pendingPushes++;
1562
- const settle = () => handleLiveness.pendingPushes--;
1563
- value.then(settle, settle);
1564
- }
1565
2045
  // Deferred (thenable) loader pushes always keep the tag — the
1566
- // bake-lane carve-out below is for settled values only.
1567
- masked = value.then(maskWithLiveness);
2046
+ // carve-out below is for settled values only.
2047
+ masked = value.then(mask);
1568
2048
  } else {
1569
- masked = maskWithLiveness(value);
1570
- if (loaderPush && !maskedNestedThenable) {
1571
- // Bake-lane { ssr: false } loader pushes are shell material (see the
1572
- // funnel comment above): untagged, the stored snapshot keeps them
1573
- // when settled and thenable-free; everything else stays tagged.
1574
- const bodyLoaderId = getCurrentLoaderBodyId();
1575
- loaderPush =
1576
- bodyLoaderId === undefined ||
1577
- derivedCtx._awaitBeforeFlushLoaderIds?.has(bodyLoaderId) !== true;
2049
+ masked = mask(value);
2050
+ if (!maskedNestedThenable) {
2051
+ // A settled, thenable-free push from a loader body is shell
2052
+ // material (see the funnel comment above): only bake-lane loaders
2053
+ // and the loaders they await run at capture, and a HIT does not run
2054
+ // a promise-free bake-lane loader (loader-cache.ts), so the record
2055
+ // keeps the push under the loader that made it. A push under a
2056
+ // live-lane loader's body, at any depth, is that hole's output: it
2057
+ // is credited to the hole, whose live run replaces it on a HIT and
2058
+ // a navigation. The first loader around the push that the route
2059
+ // registers decides: a bake-lane one keeps the push under the
2060
+ // innermost body, and a dependency on neither lane is walked past. A
2061
+ // replay of a hole's push is that hole's, unless a live-lane body
2062
+ // around it takes it first.
2063
+ const bodyLoaderId =
2064
+ liveLaneOwner() ?? getCurrentLoaderBodyId() ?? replayOwner;
2065
+ loaderPush = bodyLoaderId === undefined;
1578
2066
  if (!loaderPush) owner = bodyLoaderId;
1579
2067
  }
1580
2068
  }
2069
+ // A push the record cannot keep reaches a HIT only from a live run of
2070
+ // its loader, so the loader records ask for one (runs: 1).
2071
+ if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
1581
2072
  rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
1582
2073
  };
1583
2074
 
2075
+ // The capture reuses the record keys its request resolved (CacheScope
2076
+ // resolveKeyFrom), read through the prototype: the map must exist on the
2077
+ // request before the derivation, or the capture would start its own and
2078
+ // run key() under the capture guard.
2079
+ reqCtx._resolvedCacheKeys ??= new Map();
1584
2080
  const derivedCtx: RequestContext = Object.assign(Object.create(reqCtx), {
1585
2081
  _handleStore: freshHandleStore,
2082
+ // The capture notes the route records it reads or writes itself; the
2083
+ // foreground's (a partial request's `partial:` record, say) are not the
2084
+ // ones this shell is captured from.
2085
+ _routeRecordWindow: undefined,
1586
2086
  });
1587
2087
  if (identity) {
1588
2088
  derivedCtx.request = identity.request;
@@ -1609,11 +2109,20 @@ export function deriveShellCaptureContext(
1609
2109
  // the whole capture snapshot in memory. Capture registrations die with this
1610
2110
  // context; module-singleton stores stay registered by normal renders.
1611
2111
  derivedCtx._explicitTaggedStores = new Set();
1612
- derivedCtx._transitionWhen = [];
1613
2112
  // Own list: the capture's render errors refuse the capture only, and the
1614
2113
  // foreground's never reach it.
1615
2114
  derivedCtx._renderErrors = [];
1616
2115
  derivedCtx._shellCaptureRun = true;
2116
+ // Own dynamic() latch. The inherited method writes the FOREGROUND context
2117
+ // (its closure), which already served, and nothing read it for a runtime
2118
+ // capture: handler code that opts out after an await (a handler promise the
2119
+ // capture now waits for) must refuse THIS capture instead
2120
+ // (settleCaptureRecord and captureAndStoreShell read _dynamic). The ppr
2121
+ // header latch is left alone: a capture's response is never sent.
2122
+ derivedCtx._dynamic = false;
2123
+ derivedCtx.dynamic = (): void => {
2124
+ derivedCtx._dynamic = true;
2125
+ };
1617
2126
  // Own-property reset: an inherited flag would make serializeSegments store
1618
2127
  // a double-encoded fragment. No current arming site reaches a capture
1619
2128
  // render (kept as defense-in-depth for a future one).
@@ -1624,32 +2133,32 @@ export function deriveShellCaptureContext(
1624
2133
  // foreground rango.request span ended (orphan spans in the trace).
1625
2134
  // _tracing is otherwise inherited through Object.create(reqCtx).
1626
2135
  derivedCtx._tracing = undefined;
1627
- // Bake-lane loader containers (loaders on entries with no renderable
1628
- // loading() execute during capture — docs/design/loader-container-bake.md).
1629
- // resolveLoaderData registers each container promise here; the drain in
1630
- // captureAndStoreShell elides + pins them into the snapshot's loader family.
2136
+ // Bake-lane (`ssr: false`) loader containers, which execute during capture
2137
+ // (docs/design/loader-container-bake.md). resolveLoaderData registers each
2138
+ // container promise here; settleCaptureRecord waits for them, and the drain
2139
+ // in captureAndStoreShell elides + pins them into the snapshot's loader
2140
+ // family.
1631
2141
  derivedCtx._shellCaptureLoaderRecords = new Map();
1632
- // Own onResponse list so the capture's match-middleware callbacks (the ring-3
1633
- // segment cache write registers here) are ISOLATED from the foreground's shared
1634
- // array AND can be fired by captureAndStoreShell. The segment write is gated
1635
- // behind onResponse, which the capture never triggers (it builds no Response) —
1636
- // without firing it, a ring-3 cache() MISS at capture renders fresh into the
1637
- // prelude but is never written, so it is never recorded and drifts on a HIT.
2142
+ // Own onResponse list so the capture's match-middleware callbacks (the doc
2143
+ // record registers here) are ISOLATED from the foreground's shared array AND
2144
+ // can be fired by settleCaptureRecord. The segment write is gated behind
2145
+ // onResponse, which the capture never triggers (it builds no Response):
2146
+ // without the synthetic fire, no doc record is written.
1638
2147
  derivedCtx._onResponseCallbacks = [];
1639
2148
 
1640
- // Capture data snapshot: read every cache-store hit/write through a recording
1641
- // wrapper on the DERIVED context's store (own property, so the shared
1642
- // reqCtx._cacheStore is untouched — the snapshot is per-capture). Its records
1643
- // ride inside the ShellCacheEntry so a HIT can reproduce the shell's cached
1644
- // content byte-identically. See cache/shell-snapshot.ts and the design doc.
2149
+ // Capture data snapshot: route the capture's cache-store calls through a
2150
+ // recording wrapper on the DERIVED context's store (own property, so the
2151
+ // shared reqCtx._cacheStore is untouched — the snapshot is per-capture). It
2152
+ // records the doc segment record, which rides inside the ShellCacheEntry
2153
+ // with the bake-lane loader pins; no cache read is recorded. See
2154
+ // cache/shell-snapshot.ts and the design doc.
1645
2155
  //
1646
- // Cache writes are deferred (waitUntil): a MISS-at-capture value's setItem/set
1647
- // — hence its record — would otherwise land after the shell quiesces. Override
1648
- // the derived context's waitUntil to COLLECT those write promises (still
1649
- // forwarding to the parent so the write persists and the worker stays alive),
1650
- // then captureAndStoreShell awaits them before draining. Reads that HIT are
1651
- // recorded synchronously during the render and need none of this.
1652
- derivedCtx._shellCaptureHandleLiveness = handleLiveness;
2156
+ // Cache writes are deferred (waitUntil): the doc record's write would
2157
+ // otherwise land after the match returns. Override the derived context's
2158
+ // waitUntil to COLLECT those write promises (still forwarding to the parent
2159
+ // so the write persists and the worker stays alive), then
2160
+ // settleCaptureRecord and captureAndStoreShell await them before reading
2161
+ // the record and draining.
1653
2162
  if (reqCtx._cacheStore) {
1654
2163
  const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
1655
2164
  derivedCtx._cacheStore = recordingStore;
@@ -1658,12 +2167,13 @@ export function deriveShellCaptureContext(
1658
2167
  recordingStore.trackWrite(p);
1659
2168
  reqCtx.waitUntil(() => p);
1660
2169
  };
1661
- // Shell fast path (capture side): the implicit doc-cache scope makes the
2170
+ // The doc record (capture side): the implicit doc-cache scope makes the
1662
2171
  // capture's match write ALL matched non-loader segments as one doc-keyed
1663
2172
  // segment record — into the snapshot only (SnapshotOnlySegmentStore), so
1664
2173
  // the record dies with the shell entry and the next capture's lookup
1665
- // still misses (handlers re-run on recapture). Routes deriving their own
1666
- // cache scope are untouched (resolveShellImplicitCacheScope).
2174
+ // still misses (handlers re-run on recapture). A route deriving its own
2175
+ // cache() scope keeps it, and recordShellCaptureDocRecord (cache-store.ts)
2176
+ // writes the same record for it.
1667
2177
  derivedCtx._shellImplicitCache = {
1668
2178
  ttl: descriptor.ttl,
1669
2179
  swr: descriptor.swr,
@@ -1672,17 +2182,26 @@ export function deriveShellCaptureContext(
1672
2182
  };
1673
2183
  }
1674
2184
 
1675
- return { derivedCtx, freshHandleStore };
2185
+ return {
2186
+ derivedCtx,
2187
+ freshHandleStore,
2188
+ handlerPushesSettled: () =>
2189
+ Promise.all(handlerPushSettles).then(() => undefined),
2190
+ };
1676
2191
  }
1677
2192
 
1678
2193
  /**
1679
- * Seal handles, derive the quiesce signal, prerender + abort via the SSR module's
1680
- * captureShellHTML, and store the result. Returns the attempt outcome (the caller
1681
- * owns retry/warn decisions — this function no longer warns). Never throws out of
1682
- * the store write: a failed putShell is routed through reportCacheError so the
1683
- * background task stays best-effort, and the attempt still counts as `stored` (the
1684
- * capture worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
1685
- * present (eligibility is checked before scheduling).
2194
+ * Derive the quiesce signal, prerender + abort via the SSR module's
2195
+ * captureShellHTML, and store the result. Runs after settleCaptureRecord (both
2196
+ * producers), which sealed the capture's handle store (`reqCtx._handleStore`)
2197
+ * and waited for everything the shell bakes. Returns the attempt outcome (the
2198
+ * caller owns retry/warn decisions). Never throws out of the store write: a
2199
+ * failed putShell is routed through reportCacheError so the background task
2200
+ * stays best-effort, and the attempt still counts as `stored` (the capture
2201
+ * worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
2202
+ * present (eligibility is checked before scheduling). `captureStartedAt` is the
2203
+ * capture generation's start, stamped before the match (ShellCacheEntry
2204
+ * .createdAt).
1686
2205
  *
1687
2206
  * A `no-shell` result from captureShellHTML is the only retryable outcome. Every
1688
2207
  * captureShellHTML error propagates to reportCacheError and is NOT retried. The
@@ -1692,117 +2211,45 @@ export function deriveShellCaptureContext(
1692
2211
  async function captureAndStoreShell(
1693
2212
  ssrModule: SSRModule,
1694
2213
  rscStream: ReadableStream<Uint8Array>,
1695
- handleStore: HandleStore,
1696
2214
  reqCtx: RequestContext<any>,
1697
2215
  capture: ShellCaptureDescriptor,
1698
- stats?: CaptureAttemptStats,
1699
- captureStartedAt: number = Date.now(),
2216
+ captureStartedAt: number,
2217
+ stats: CaptureAttemptStats = {},
1700
2218
  ): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
1701
2219
  const captureShellHTML = ssrModule.captureShellHTML!;
1702
2220
 
1703
- // Seal the handle store so the payload's handles generator (resolvedHandleStream
1704
- // -> handleStore.stream()) converges and completes even though masked loaders
1705
- // never resolve. handleStore.settled gates ONLY on tracked HANDLER promises
1706
- // (handleStore.track, via trackHandler) — NOT on deferred handle VALUES pushed
1707
- // through ctx.use(Handle).defer(), which are plain pushed promises. So seal()
1708
- // does not reject or hang on outstanding defers: settled resolves once the
1709
- // handlers settle, and each deferred slot resolves on its own createDeferred
1710
- // timeout (defer.ts, default 10s) or when its resolver fires. A defer whose
1711
- // resolver depends on a masked loader can never fire, so it stays pending until
1712
- // that 10s timeout — longer than maxWaitMs (5s). At the abort the handles
1713
- // generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
1714
- // sits above every boundary), the prelude comes back trivial, and
1715
- // captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
1716
- // an error. This mirrors the matchForPrerender (router/prerender-match.ts)
1717
- // seal+settled regime, which also excludes loaders. See
1718
- // docs/design/ppr-shell-resume.md ("Loaders and handles").
1719
- handleStore.seal();
1720
-
1721
- // Handles contract, shell half ("nesting = liveness"): TOP-LEVEL pushed handle
1722
- // promises are BAKED into the shell — resolvedHandleStream awaits them before
1723
- // the payload's handles row emits. A pushed promise with real latency would lose
1724
- // the byte-quiet race (the pending handles row emits no bytes, the gate freezes,
1725
- // the row is dropped, SsrRoot suspends at the root), so the gate is HELD open
1726
- // until the same await completes: handlesBaked mirrors resolvedHandleStream's
1727
- // resolution (getData waits the tracked-handler barrier; resolveDeferredHandleValues
1728
- // awaits the top-level thenables). NESTED promises inside pushed containers are
1729
- // shallow-skipped by isThenable and never hold the gate — they stay holes.
1730
- // Bounded by maxWaitMs like every quiesce input (a defer hanging on a masked
1731
- // loader still ends in the sanity-gate refusal).
1732
- const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
1733
- // Bake-lane loader containers hold the gate the same way (loader-container-
1734
- // bake): a boundary-less container with real latency (a 100ms layout loader)
1735
- // would otherwise lose the 2-hop byte-quiet race — the pending loaderData row
1736
- // emits no bytes, the gate freezes, and the awaiting tree pins above <body>.
1737
- // The records map is fully populated before this point (loader promises are
1738
- // created during the capture's match()), so the hold covers every bake-lane
1739
- // container. allSettled: a REJECTED container releases the hold (the drain
1740
- // below refuses the capture); nested promises INSIDE a container never hold
1741
- // the gate — they stay holes. Bounded by maxWaitMs like every quiesce input.
1742
- const loaderRecordsForHold = reqCtx._shellCaptureLoaderRecords;
1743
- const holdUntil =
1744
- loaderRecordsForHold && loaderRecordsForHold.size > 0
1745
- ? Promise.allSettled([handlesBaked, ...loaderRecordsForHold.values()])
1746
- : handlesBaked;
1747
- // Bake-cost attribution: record how long each bake source held the gate,
1748
- // measured from capture start. Side-channel observers only — holdUntil and
1749
- // its consumers are untouched, and a rejected source is recorded the same
1750
- // (allSettled swallows it; the drain below owns the refusal). Folded into
1751
- // stats.bakeWaitMs (+ a once-per-key dev warning) after a successful
1752
- // capture; on a refusal the other warnings own the story.
1753
- const bakeStart = performance.now();
1754
- const bakeWaits: { source: string; ms: number }[] = [];
1755
- const observeBake = (source: string, promise: Promise<unknown>): void => {
1756
- const record = (): void => {
1757
- bakeWaits.push({
1758
- source,
1759
- ms: Math.round(performance.now() - bakeStart),
1760
- });
1761
- };
1762
- promise.then(record, record);
1763
- };
1764
- observeBake("top-level pushed handle promises", handlesBaked);
1765
- if (loaderRecordsForHold) {
1766
- for (const [loaderId, promise] of loaderRecordsForHold) {
1767
- observeBake(`bake-lane segment loader "${loaderId}"`, promise);
1768
- }
1769
- }
1770
- const gate = gateFlightForCapture(rscStream, undefined, holdUntil);
2221
+ const gate = gateFlightForCapture(rscStream);
1771
2222
  // Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
1772
2223
  // half stalling is bounded by captureShellHTML's maxWaitMs.
1773
- const quiesce = Promise.all([handleStore.settled, gate.quiesce]).then(
2224
+ const quiesce = Promise.all([reqCtx._handleStore.settled, gate.quiesce]).then(
1774
2225
  () => {},
1775
2226
  );
1776
2227
 
1777
- // Deterministic identity-guard refusal, checked at BOTH exits below: the
1778
- // guard error either rejects the prerender itself (boundary-less segment —
1779
- // lands in the catch) or is swallowed into per-loader error UI (the render
1780
- // completes — caught after the try). One helper so the message and the
1781
- // "refused" mapping cannot drift between the two sites.
1782
- const refuseOnGuardTrip = (): "refused" | undefined => {
1783
- const fnName = reqCtx._shellCaptureGuardTripped;
1784
- if (!fnName) return undefined;
1785
- // Name the recorded source instead of hardcoding a lane. Under the
1786
- // consumption-lane rule, handler-INVOKED loader bodies are exempt from
1787
- // the guard (their value is a baked shared copy, mirroring cache()), so a
1788
- // trip can only come from a bake-lane SEGMENT loader or from non-loader
1789
- // handler/render code — the old blanket "bake-lane loader" attribution
1790
- // sent a live-lane debugging session down the wrong lane (issue #672).
1791
- const loaderId = reqCtx._shellCaptureGuardTrippedLoaderId;
1792
- const origin = loaderId
1793
- ? `segment loader "${loaderId}"`
1794
- : "handler/render code (no loader body was executing)";
1795
- warnCaptureRefusedOnce(
1796
- capture.key,
1797
- `${origin} called ${fnName}() during capture; identity must not bake into the shared shell. ` +
1798
- "Segment loader: drop ssr: false and read it under loading() or an inline <Suspense> " +
1799
- "(a nested promise does not help: its body still runs at capture). Handler/render " +
1800
- "code: read it in a live loader and consume it with useLoader. Exception: " +
1801
- "await ctx.use(loader) in a HANDLER bakes as a capture-time copy.",
1802
- PPR_LANE_HINT,
1803
- );
1804
- return "refused";
1805
- };
2228
+ // Deterministic capture-guard refusal (refuseOnCaptureGuard), checked at
2229
+ // BOTH exits below: the guard error either rejects the prerender itself
2230
+ // (boundary-less segment — lands in the catch) or is swallowed into
2231
+ // per-loader error UI (the render completes — caught after the try).
2232
+ const refuseOnGuardTrip = (): "refused" | undefined =>
2233
+ refuseOnCaptureGuard(capture.key, reqCtx) ? "refused" : undefined;
2234
+
2235
+ // Dev diagnostics for a no-shell attempt (warnNullCaptureOnce): the stacks of
2236
+ // the tasks React reports as still pending at the capture's abort. React's
2237
+ // componentStack is computed on read, so it is read only while there is room.
2238
+ let pendingStacks: string[] | undefined;
2239
+ let onAbortedTask:
2240
+ | ((errorInfo: { componentStack?: string } | undefined) => void)
2241
+ | undefined;
2242
+ if (process.env.NODE_ENV !== "production") {
2243
+ const stacks: string[] = [];
2244
+ pendingStacks = stacks;
2245
+ onAbortedTask = (errorInfo) => {
2246
+ if (stacks.length >= MAX_PENDING_STACKS) return;
2247
+ const componentStack = errorInfo?.componentStack;
2248
+ if (componentStack && !stacks.includes(componentStack)) {
2249
+ stacks.push(componentStack);
2250
+ }
2251
+ };
2252
+ }
1806
2253
 
1807
2254
  try {
1808
2255
  // captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
@@ -1821,6 +2268,7 @@ async function captureAndStoreShell(
1821
2268
  onError: (error) => {
1822
2269
  reqCtx._renderErrors?.push(error);
1823
2270
  },
2271
+ onAbortedTask,
1824
2272
  }),
1825
2273
  );
1826
2274
  } catch (error) {
@@ -1848,71 +2296,24 @@ async function captureAndStoreShell(
1848
2296
  if (refused) return refused;
1849
2297
 
1850
2298
  if (result === null) {
2299
+ stats.pendingStacks = pendingStacks;
1851
2300
  return "no-shell";
1852
2301
  }
1853
- if (stats) stats.preludeBytes = result.prelude.length;
1854
-
1855
- // Every bake source has settled by here (quiesce gates on holdUntil).
1856
- // Surface the slowest one: sub-ms settles are noise, threshold-crossers
1857
- // get the dev warning with the remedy ladder.
1858
- let slowestBake: { source: string; ms: number } | undefined;
1859
- for (const wait of bakeWaits) {
1860
- if (!slowestBake || wait.ms > slowestBake.ms) slowestBake = wait;
1861
- }
1862
- if (slowestBake && slowestBake.ms > 0) {
1863
- if (stats) stats.bakeWaitMs = slowestBake.ms;
1864
- if (
1865
- process.env.NODE_ENV !== "production" &&
1866
- slowestBake.ms >= SHELL_CAPTURE_BAKE_WARN_MS
1867
- ) {
1868
- warnBakeCostOnce(capture.key, slowestBake.source, slowestBake.ms);
1869
- }
1870
- }
1871
-
1872
- // Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
1873
- // threads the SAME store it resolved for its getShell read (options.store ??
1874
- // _cacheStore), so a store-attached middleware writes captures where it reads
1875
- // them. The _cacheStore fallback covers a flag armed without a store (tests).
1876
- // reactVersion is read from the same React.version import the middleware
1877
- // validates reads against, so capture and serve always agree.
1878
- // Fire the capture's isolated onResponse callbacks with a synthetic 200 so
1879
- // the ring-3 segment cache write (cacheScope.cacheRoute, registered via
1880
- // onResponse by the cache-store match-middleware and gated on a 200) runs
1881
- // DURING capture, routed through the recording store. The foreground path
1882
- // never fires for the capture — it builds no Response — so without this a
1883
- // cache() SEGMENT that MISSED at capture would be rendered fresh into the
1884
- // prelude yet never written, hence never recorded, and would drift on a HIT
1885
- // (an item-family "use cache" write already runs inline during the render, so
1886
- // it needs none of this; only segment writes are onResponse-gated). The
1887
- // derived context's own _onResponseCallbacks holds only capture match-
1888
- // middleware callbacks (HTTP middleware never runs for a capture), so firing
1889
- // them is safe. Best-effort: a throwing callback must not fail the capture.
1890
- const responseCallbacks = reqCtx._onResponseCallbacks;
1891
- if (responseCallbacks && responseCallbacks.length > 0) {
1892
- const synthetic = new Response(null, { status: 200 });
1893
- for (const cb of responseCallbacks) {
1894
- try {
1895
- cb(synthetic);
1896
- } catch {
1897
- // A capture-time cache write that throws is degradation, not failure.
1898
- }
1899
- }
1900
- }
1901
-
1902
- // Drain the capture data snapshot from the recording store on the derived
1903
- // context. Await the deferred cache writes first so a MISS-at-capture value
1904
- // (setItem/set scheduled under waitUntil, including the segment write just
1905
- // fired) is pinned, not just read-hits. When no recording store is installed
1906
- // (unit tests that call this directly), there is simply no snapshot.
2302
+ stats.preludeBytes = result.prelude.length;
2303
+
2304
+ // Drain the doc record from the recording store on the derived context.
2305
+ // The capture's own deferred cache writes ("use cache" and loader
2306
+ // cache() misses made while it rendered) settle first, bounded, so the
2307
+ // next HIT's holes find them in the store. When no recording store is
2308
+ // installed (unit tests that call this directly), the snapshot starts
2309
+ // empty.
1907
2310
  const recording = getRecordingStore(reqCtx._cacheStore);
1908
- let snapshot: ShellSnapshotRecord[] | undefined;
2311
+ let snapshot: ShellSnapshotRecord[] = [];
1909
2312
  if (recording) {
1910
2313
  const settleStart = performance.now();
1911
2314
  await recording.settleWrites(SHELL_SNAPSHOT_WRITE_SETTLE_MS);
1912
- if (stats) {
1913
- stats.writeSettleMs = Math.round(performance.now() - settleStart);
1914
- }
1915
- snapshot = recording.drainSnapshot();
2315
+ stats.writeSettleMs = Math.round(performance.now() - settleStart);
2316
+ snapshot = recording.drainSnapshot() ?? [];
1916
2317
  }
1917
2318
 
1918
2319
  // Pin the bake-lane loader containers (loader family). Settled containers
@@ -1934,6 +2335,8 @@ async function captureAndStoreShell(
1934
2335
  let serializeContainer:
1935
2336
  | typeof import("../cache/segment-codec.js").serializeResult
1936
2337
  | undefined;
2338
+ // Capture-wide, read once: every record of this snapshot agrees.
2339
+ const runs: 0 | 1 = reqCtx._shellCaptureUnrecordedLoaderPush ? 1 : 0;
1937
2340
  for (const [segmentKey, containerPromise] of loaderRecords) {
1938
2341
  const elided = await elideLoaderContainer(containerPromise);
1939
2342
  if (elided.state === "rejected") {
@@ -1979,14 +2382,18 @@ async function captureAndStoreShell(
1979
2382
  reqCtx._renderErrors?.push(error);
1980
2383
  });
1981
2384
  if (serialized !== null) {
1982
- (snapshot ??= []).push({
2385
+ snapshot.push({
1983
2386
  family: "loader",
1984
2387
  key: segmentKey,
1985
2388
  // The hole bit rides with the record so the HIT overlay knows
1986
2389
  // without rescanning whether the pin can resolve immediately
1987
2390
  // (holes: 0) or must wait for the fresh run's live promises
1988
2391
  // (holes: 1). See ShellSnapshotLoaderValue.
1989
- value: { value: serialized, holes: elided.hasHole ? 1 : 0 },
2392
+ value: {
2393
+ value: serialized,
2394
+ holes: elided.hasHole ? 1 : 0,
2395
+ runs,
2396
+ },
1990
2397
  });
1991
2398
  }
1992
2399
  } catch {
@@ -2006,50 +2413,114 @@ async function captureAndStoreShell(
2006
2413
  const renderErrors = reqCtx._renderErrors;
2007
2414
  if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
2008
2415
 
2009
- // Snapshot size guard (issue #651): the snapshot duplicates every pinned
2010
- // cache value inside the shell entry, so a page over a large cache()
2011
- // segment can push the stored envelope toward store value limits (KV caps
2012
- // a value at 25 MiB) with no signal — the kv.put rejects deep inside
2013
- // waitUntil. Measure the serialized snapshot (UTF-8 bytes of the JSON that
2014
- // rides in the envelope) AFTER the loader family is appended, and over the
2015
- // cap store the shell WITHOUT it: pinned reads then fall back to the live
2016
- // store on a HIT (documented drift — hydration repairs a mismatch
2017
- // client-side, the pre-snapshot behavior), which beats losing the whole
2018
- // entry to a store-side write rejection. Reported once per key.
2019
- if (snapshot && snapshot.length > 0) {
2020
- const snapshotBytes = SNAPSHOT_BYTE_ENCODER.encode(
2021
- JSON.stringify(snapshot),
2022
- ).length;
2023
- if (stats) stats.snapshotBytes = snapshotBytes;
2416
+ // Store only what a HIT reads (issue #941): the doc record and, for a
2417
+ // document entry, the bake-lane loader pins. Before the size guards, so
2418
+ // they measure what is stored.
2419
+ const docKey = reqCtx._shellImplicitCache?.docKey;
2420
+ let prunedRecords: string | undefined;
2421
+ const { kept, pruned } = pruneShellSnapshot(
2422
+ snapshot,
2423
+ capture.navigationOnly === true,
2424
+ docKey,
2425
+ );
2426
+ if (pruned.length > 0) {
2427
+ prunedRecords = countSnapshotFamilies(pruned);
2428
+ snapshot = kept;
2429
+ }
2430
+
2431
+ // Snapshot size guard (issue #651): `maxSnapshotBytes` bounds the loader
2432
+ // pins; over it they are dropped and the entry keeps its doc record, so
2433
+ // every HIT still replays the handler layer (the pins' loaders then run
2434
+ // on the HIT: documented drift, repaired client-side). The doc record is
2435
+ // exempt: without it a HIT could not serve at all. The whole entry is
2436
+ // bounded separately below, by the store's value limit.
2437
+ if (snapshot.length > 0) {
2438
+ const docRecord =
2439
+ docKey !== undefined
2440
+ ? snapshot.find((r) => r.family === "segment" && r.key === docKey)
2441
+ : undefined;
2442
+ const pins = docRecord
2443
+ ? snapshot.filter((r) => r !== docRecord)
2444
+ : snapshot;
2445
+ const pinBytes =
2446
+ pins.length > 0
2447
+ ? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
2448
+ : 0;
2449
+ stats.snapshotBytes = pinBytes;
2024
2450
  const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
2025
- if (snapshotBytes > cap) {
2026
- warnSnapshotOverCapOnce(capture.key, snapshotBytes, cap);
2027
- snapshot = undefined;
2028
- if (stats) stats.snapshotSkipped = true;
2451
+ if (pinBytes > cap) {
2452
+ warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
2453
+ snapshot = docRecord ? [docRecord] : [];
2454
+ stats.snapshotSkipped = true;
2029
2455
  }
2030
2456
  }
2031
2457
 
2032
- // Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
2033
- // here the capture has quiesced and the deferred cache writes were awaited, so
2034
- // tags recorded AFTER an await in async shell content (and by async
2035
- // cache()/"use cache" reads propagating through recordRequestTags) are
2036
- // included — issue #676. Loaders are masked, so loader cache tags — which
2037
- // belong to the holes, not the shell — never execute during capture and cannot
2038
- // contribute. Union with the route's static ppr.tags (capture.tags); the
2039
- // collected set is authoritative, the option only adds what the render cannot
2040
- // know.
2041
- const collected = [...reqCtx._requestTags];
2458
+ // settleCaptureRecord refused every capture without a doc record except a
2459
+ // prerender-served one (the prerender store supplies its handler layer on
2460
+ // every HIT), and the record survives the drain, the pruning and the size
2461
+ // cap above.
2462
+ const storedDocKey = hasDocRecord(snapshot, docKey) ? docKey : undefined;
2463
+
2464
+ // Shell tags: what the shell renders from, and nothing else. The handler
2465
+ // layer is the doc record, whose tags are what its content recorded (the
2466
+ // handlers, the server components its serialization rendered, the loaders
2467
+ // they consumed, the handle values; cache-scope.ts collectRecordTags), or
2468
+ // what a replayed route cache() record carried (recordSegmentTags). So a
2469
+ // capture that rendered fresh and one that replayed the route's record
2470
+ // store the same tags. Bake-lane loaders add theirs (SHELL_BAKE_TAG_OWNER):
2471
+ // their data is in the shell, and no handler reads it. A live-lane loader
2472
+ // never runs at capture, and one whose cache() config tags are recorded
2473
+ // anyway is a hole's, so request-level tags are not the source. Read at
2474
+ // the write barrier: the record settled and the deferred writes were
2475
+ // awaited, so a tag recorded after an await (#676) is in. A capture
2476
+ // without a doc record (the prerender store served its match) keeps the
2477
+ // request-level set. Union with the route's static ppr.tags.
2478
+ const docRecordValue = storedDocKey
2479
+ ? (snapshot.find((r) => r.family === "segment" && r.key === storedDocKey)
2480
+ ?.value as CachedEntryData | undefined)
2481
+ : undefined;
2482
+ const collected = docRecordValue
2483
+ ? [
2484
+ ...(docRecordValue.tags ?? []),
2485
+ ...getSegmentTags(reqCtx, SHELL_BAKE_TAG_OWNER),
2486
+ ]
2487
+ : [...reqCtx._requestTags];
2042
2488
  const union = new Set<string>([...(capture.tags ?? []), ...collected]);
2043
2489
  const shellTags = union.size > 0 ? [...union] : undefined;
2044
2490
 
2491
+ // Whole-entry guard: the prelude, postponed state, snapshot, and tags
2492
+ // travel in one stored value. Over the store's limit (Cloudflare KV:
2493
+ // 25 MiB) the write would fail inside waitUntil and every MISS would
2494
+ // recapture, so refuse here (the key backs off) instead. Measured as the
2495
+ // stores write it (estimateShellEntryBytes): the postponed state rides a
2496
+ // JSON head as an escaped string.
2497
+ const entryBytes = estimateShellEntryBytes({
2498
+ // A navigation-only entry stores no document half.
2499
+ preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
2500
+ postponed: capture.navigationOnly ? undefined : result.postponed,
2501
+ snapshot,
2502
+ tags: shellTags,
2503
+ docKey: storedDocKey,
2504
+ prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
2505
+ });
2506
+ stats.entryBytes = entryBytes;
2507
+ const entryLimit =
2508
+ (capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
2509
+ DEFAULT_SHELL_ENTRY_MAX_BYTES;
2510
+ if (entryBytes > entryLimit) {
2511
+ warnCaptureRefusedOnce(
2512
+ capture.key,
2513
+ `the shell entry is ${entryBytes} bytes (prelude, postponed state, ` +
2514
+ `snapshot, and head), over the store's ${entryLimit}-byte value limit. ` +
2515
+ "Shrink the page the shell bakes, or move large regions under a live " +
2516
+ "loader's boundary.",
2517
+ );
2518
+ return "refused";
2519
+ }
2520
+
2045
2521
  // Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
2046
2522
  // that choice only to operators who enabled structured capture diagnostics.
2047
- if (
2048
- capture.debugSink &&
2049
- stats &&
2050
- bakedLoaderMaterial &&
2051
- shellTags === undefined
2052
- ) {
2523
+ if (capture.debugSink && bakedLoaderMaterial && shellTags === undefined) {
2053
2524
  stats.untaggedBake = true;
2054
2525
  }
2055
2526
 
@@ -2065,48 +2536,76 @@ async function captureAndStoreShell(
2065
2536
  ...(capture.navigationOnly
2066
2537
  ? {}
2067
2538
  : {
2068
- // slice() copies just this view's bytes into a fresh
2069
- // ArrayBuffer, so a prelude that is a subarray of a larger
2070
- // backing buffer encodes only its own region — bufferToBase64
2071
- // reads the whole ArrayBuffer it is handed.
2072
- prelude: bufferToBase64(
2073
- result.prelude.slice().buffer as ArrayBuffer,
2074
- ),
2539
+ // bytesToBase64 encodes the view's own region, so a prelude
2540
+ // that is a subarray of a larger buffer encodes correctly.
2541
+ prelude: bytesToBase64(result.prelude),
2075
2542
  postponed: result.postponed,
2076
2543
  }),
2077
2544
  reactVersion: React.version,
2078
2545
  buildVersion: capture.buildVersion,
2079
2546
  // The theme this capture's payload was built with (buildFullPayload
2080
- // reads reqCtx.theme off the derived context). The serve tail replays
2081
- // it so the resume tree matches the frozen prelude — see
2082
- // ShellCacheEntry.initialTheme.
2083
- initialTheme: reqCtx.theme,
2547
+ // reads payloadInitialTheme off the derived context: the no-cookie
2548
+ // default, #971). The serve tail replays it so the resume tree
2549
+ // matches the frozen prelude — see ShellCacheEntry.initialTheme.
2550
+ initialTheme: payloadInitialTheme(reqCtx),
2084
2551
  snapshot,
2552
+ prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
2085
2553
  // The canonical doc segment record's key, published by the doc
2086
- // scope's cacheRoute during this capture's match (undefined when no
2087
- // doc record was recorded — the entry then never claims navigation
2088
- // replayability). Only meaningful alongside a snapshot.
2089
- docKey: snapshot ? reqCtx._shellImplicitCache?.docKey : undefined,
2554
+ // scope's cacheRoute during this capture's match. Every HIT tail
2555
+ // looks the record up by this key (serveShellHit fixedDocKey);
2556
+ // undefined only for a prerender-served entry.
2557
+ docKey: storedDocKey,
2090
2558
  navigationOnly: capture.navigationOnly,
2091
- // Handler-layer liveness folded at the barrier: nested thenables in
2092
- // handler-scoped pushes, handler pushes still pending (liveness
2093
- // unknowable), or a handler-invoked loader execution — any of them
2094
- // refuses the FAST PATH, not the capture. See
2095
- // _shellCaptureHandleLiveness.
2096
- handlerLiveHoles: handlerLayerIsLive(
2097
- reqCtx._shellCaptureHandleLiveness,
2098
- ),
2099
- transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
2100
2559
  createdAt: captureStartedAt,
2101
2560
  };
2561
+ // Capped to the route record the capture used: the policy the
2562
+ // store would apply (resolveTtl/resolveSwrWindow, as putShell does),
2563
+ // made explicit.
2564
+ const record = reqCtx._routeRecordWindow;
2565
+ let window: { ttl?: number; swr?: number } = {
2566
+ ttl: capture.ttl,
2567
+ swr: capture.swr,
2568
+ };
2569
+ if (record) {
2570
+ const capped = capShellWindow(
2571
+ resolveTtl(capture.ttl, store.defaults, DEFAULT_FUNCTION_TTL),
2572
+ resolveSwrWindow(capture.swr, store.defaults),
2573
+ record,
2574
+ Date.now(),
2575
+ );
2576
+ if (!capped) {
2577
+ // Dead on arrival, not stored. A record this attempt wrote ran
2578
+ // out between its write and the store, which a retry repeats:
2579
+ // warn, refuse, back off. A record it read was near its end (its
2580
+ // age, queue wait): `expired` retries on a fresh match.
2581
+ const route = reqCtx._routeName ?? capture.key;
2582
+ if (record.written) {
2583
+ const sinceWriteMs =
2584
+ Date.now() - (record.freshUntil - record.ttl * 1000);
2585
+ warnRecordRanOutOnce(
2586
+ route,
2587
+ record,
2588
+ `${sinceWriteMs} ms after this capture wrote it`,
2589
+ );
2590
+ return "refused";
2591
+ }
2592
+ stats.expired = {
2593
+ route,
2594
+ record,
2595
+ captureMs: Date.now() - captureStartedAt,
2596
+ };
2597
+ return "expired";
2598
+ }
2599
+ window = capped;
2600
+ }
2102
2601
  const storeWrite = await store.putShell(
2103
2602
  capture.key,
2104
2603
  entry,
2105
- capture.ttl,
2106
- capture.swr,
2604
+ window.ttl,
2605
+ window.swr,
2107
2606
  shellTags,
2108
2607
  );
2109
- if (stats && storeWrite) stats.storeWrite = storeWrite;
2608
+ if (storeWrite) stats.storeWrite = storeWrite;
2110
2609
  if (storeWrite === "invalidated") {
2111
2610
  warnCaptureRefusedOnce(
2112
2611
  capture.key,
@@ -2176,3 +2675,18 @@ export {
2176
2675
  REFUSED_CAPTURE_MAX_MS,
2177
2676
  REFUSED_CAPTURE_DEV_MAX_MS,
2178
2677
  };
2678
+
2679
+ /**
2680
+ * @internal Reset this module's per-isolate state: the capture stampede
2681
+ * guard, the refused-capture backoff, the once-per-key warnings (the shell
2682
+ * path's one registry), the stored-capture sequence and the buffered debug
2683
+ * events. Tests only (testing/serve-shell-request.ts resetShellTestState),
2684
+ * between requests, never while a capture runs.
2685
+ */
2686
+ export function resetShellCaptureStateForTests(): void {
2687
+ inFlightCaptures.clear();
2688
+ refusedCaptures.clear();
2689
+ resetShellWarningsForTests();
2690
+ storedCaptureSeqs.clear();
2691
+ lastCaptureEventsForTiming.clear();
2692
+ }