@rangojs/router 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -17,8 +17,9 @@
17
17
  */
18
18
 
19
19
  import React from "react";
20
- import { isLoaderDataResult } from "../types.js";
21
- import { bufferToBase64 } from "../cache/cf/cf-base64.js";
20
+ import { isLoaderDataResult, type MatchResult } from "../types.js";
21
+ import { bytesToBase64 } from "../cache/cf/cf-base64.js";
22
+ import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
22
23
  import { reportCacheError } from "../cache/cache-error.js";
23
24
  import { runBackground } from "../cache/background-task.js";
24
25
  import {
@@ -28,6 +29,7 @@ import {
28
29
  enqueueSerializedCapture,
29
30
  } from "./capture-queue.js";
30
31
  import {
32
+ PPR_LANE_HINT,
31
33
  SHELL_CAPTURE_MAX_WAIT_MS,
32
34
  SHELL_CAPTURE_TASK_HARD_CAP_MS,
33
35
  } from "./shell-capture-constants.js";
@@ -40,10 +42,17 @@ import {
40
42
  wireRenderBarrier,
41
43
  UNTRACKED_BACKGROUND_TASK,
42
44
  type RequestContext,
45
+ type RouteRecordWindow,
43
46
  } from "../server/request-context.js";
47
+ import {
48
+ DEFAULT_FUNCTION_TTL,
49
+ resolveSwrWindow,
50
+ resolveTtl,
51
+ } from "../cache/cache-policy.js";
44
52
  import { createHandleStore, type HandleStore } from "../server/handle-store.js";
45
53
  import {
46
54
  maskNestedContainerThenables,
55
+ settleNestedThenables,
47
56
  type MaskReport,
48
57
  } from "../router/segment-resolution/mask-nested.js";
49
58
  import {
@@ -52,6 +61,7 @@ import {
52
61
  } from "../server/context.js";
53
62
  import { isThenable } from "../handles/is-thenable.js";
54
63
  import type {
64
+ CachedEntryData,
55
65
  ShellCacheEntry,
56
66
  SegmentCacheStore,
57
67
  ShellSnapshotRecord,
@@ -63,11 +73,16 @@ import {
63
73
  import {
64
74
  RecordingShellStore,
65
75
  SnapshotOnlySegmentStore,
76
+ countSnapshotFamilies,
77
+ estimateShellEntryBytes,
66
78
  getRecordingStore,
79
+ hasDocRecord,
80
+ pruneShellSnapshot,
81
+ type ShellSnapshotReaders,
67
82
  } from "../cache/shell-snapshot.js";
68
83
  import type { HandlerContext } from "./handler-context.js";
69
84
  import type { SSRModule } from "./types.js";
70
- import { buildFullPayload } from "./full-payload.js";
85
+ import { buildFullPayload, payloadInitialTheme } from "./full-payload.js";
71
86
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
72
87
  import { renderRscFlightStage } from "./render-pipeline.js";
73
88
  import { stripInternalParams } from "../router/handler-context.js";
@@ -257,10 +272,11 @@ function releaseCaptureGuard(key: string, token: CaptureGuardToken): void {
257
272
  * mode's ceiling (60s in production, {@link REFUSED_CAPTURE_DEV_MAX_MS} in dev).
258
273
  *
259
274
  * Why exponential and not a flat 60s: a flat long window conflates two very
260
- * different failures. A STRUCTURALLY ineligible route (no loading(), a cookie
261
- * reader) fails forever and wants the long 60s cap. But a cold-but-ELIGIBLE route
262
- * can also fail the in-place retry under a truly cold graph (dev module transform,
263
- * or a cold worker under parallel load) — and it must recover FAST, on the next
275
+ * different failures. A STRUCTURALLY ineligible route (a boundary-less live
276
+ * loader read, a cookie reader) fails forever and wants the long 60s cap. But a
277
+ * cold-but-ELIGIBLE route can also fail the in-place retry under a truly cold
278
+ * graph (dev module transform, or a cold worker under parallel load) — and it
279
+ * must recover FAST, on the next
264
280
  * request or two, not be frozen for 60s (that would re-break the very cold-start DX
265
281
  * the retry fixes; it bit the cloudflare dev e2e). Escalating from 1s means the
266
282
  * eligible route re-probes almost immediately (warm now → HIT and clear), while the
@@ -357,8 +373,9 @@ function clearCaptureBackoff(key: string): void {
357
373
  const warnedNullCaptures = new Set<string>();
358
374
 
359
375
  /**
360
- * Warn once per key that a capture produced no usable shell EVEN AFTER the
361
- * in-place retry (runShellCapture attempt 2). Naming both causes with the
376
+ * Warn once per key that a capture produced no usable shell: after the
377
+ * in-place retry (runShellCapture attempt 2), or after one attempt that ran
378
+ * out of its deadline (no retry: noShellCauses). Naming both causes with the
362
379
  * distinguishing signal — does the route ever flip to HIT — is the whole point:
363
380
  * the pre-retry version blamed "a loader route without loading()" unconditionally
364
381
  * and misled users whose route DID have loading() and was merely cold. Because the
@@ -366,55 +383,164 @@ const warnedNullCaptures = new Set<string>();
366
383
  * usually healed, so a firing warning leans toward the structural cause — but we
367
384
  * still name both so a cold-start straggler is not misdiagnosed.
368
385
  *
369
- * The pointer is shipped-path-safe (a05c8251 convention): the /ppr skill ships in
370
- * the npm tarball, but docs/design/ is repo-only, so link it by absolute GitHub URL
371
- * rather than a relative path that dead-ends for consumers.
386
+ * The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
387
+ * tarball, so the path resolves for consumers (a05c8251 convention).
372
388
  */
373
- function warnNullCaptureOnce(key: string): void {
389
+ function warnNullCaptureOnce(key: string, retried: boolean): void {
390
+ const pendingStacks = noShellPendingStacks.get(key);
391
+ noShellPendingStacks.delete(key);
392
+ const cause = noShellCauses.get(key);
393
+ noShellCauses.delete(key);
374
394
  if (warnedNullCaptures.has(key)) return;
375
395
  warnedNullCaptures.add(key);
376
396
  console.warn(
377
- `[rango] Shell capture for "${key}" produced no usable shell after an in-place ` +
378
- "retry; nothing was stored, so this request stays on MISS. Causes, told apart " +
379
- "by whether the route ever flips to HIT:\n" +
380
- " 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
381
- "an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
382
- "request warms the modules. Usually nothing to do.\n" +
383
- " 2. Something suspends above <body> with no Suspense boundary and never settles " +
384
- "within the capture window: a slower-than-the-capture-guard bake-lane loader " +
385
- "(loaders on entries WITHOUT loading() execute at capture and their containers " +
386
- "bake — the boundary-less await must settle for a shell to exist), or a pending " +
387
- "promise consumed without a <Suspense> above it. The boundary belongs on the " +
388
- "entry/component that OWNS the data: loading() on the entry that registers the " +
389
- "loader (a child route's loading() does not unpin a parent layout's loaders), or " +
390
- "a <Suspense> above the consuming component.\n" +
391
- 'See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md), "The hole ' +
392
- 'doctrine" and "The layout-with-loaders playbook", or the design docs: ' +
393
- "https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/design/ppr-shell-resume.md",
397
+ `[rango] Shell capture for "${key}" produced no usable shell` +
398
+ (retried ? " after an in-place retry" : "") +
399
+ "; nothing was stored, so this request stays on MISS. " +
400
+ (cause
401
+ ? `Cause: ${cause}. Every capture waits for the handler layer to finish and ` +
402
+ 'bakes it; make the slow part cheaper (cache() or "use cache"), raise ' +
403
+ "ppr.captureTimeout, or move it into a live loader (no ssr: false) read under " +
404
+ "loading() or an inline <Suspense>.\n"
405
+ : "Causes, told apart by whether the route ever flips to HIT:\n" +
406
+ " 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
407
+ "an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
408
+ "request warms the modules.\n" +
409
+ " 2. Something suspends above <body> with no Suspense boundary and never settles " +
410
+ "within the capture window: a live loader (no ssr: false) read without loading() or " +
411
+ "an inline <Suspense>, or a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
412
+ "slower than ppr.captureTimeout. The boundary belongs to the entry or component " +
413
+ "that owns the data.\n") +
414
+ (pendingStacks && pendingStacks.length > 0
415
+ ? "Components still pending when the capture froze the shell (one of them " +
416
+ "suspended above <body>):\n" +
417
+ pendingStacks.map(formatPendingStack).join("\n") +
418
+ "\n"
419
+ : "") +
420
+ PPR_LANE_HINT,
394
421
  );
395
422
  }
396
423
 
424
+ /**
425
+ * Why the latest no-shell attempt per key ran out of ppr.captureTimeout (the
426
+ * match, the handler output, or the prerender after them). Set only for a
427
+ * deadline outcome, so runShellCapture also reads it to skip the in-place
428
+ * retry. Printed (and cleared) by warnNullCaptureOnce.
429
+ */
430
+ const noShellCauses = new Map<string, string>();
431
+
432
+ /**
433
+ * Dev only: component stacks of the tasks still pending at the latest
434
+ * no-shell attempt's abort, per key, printed (and cleared) by
435
+ * warnNullCaptureOnce. React reports them through prerender's onError
436
+ * (captureShellHTML's onAbortedTask).
437
+ */
438
+ const noShellPendingStacks = new Map<string, string[]>();
439
+
440
+ /** Stacks kept per attempt: the root pin is almost always among the first. */
441
+ const MAX_PENDING_STACKS = 3;
442
+
443
+ /** The first lines of one component stack, indented under the warning. */
444
+ function formatPendingStack(stack: string): string {
445
+ return stack
446
+ .split("\n")
447
+ .map((line) => line.trim())
448
+ .filter(Boolean)
449
+ .slice(0, 8)
450
+ .map((line) => ` ${line}`)
451
+ .join("\n");
452
+ }
453
+
397
454
  /** Keys already warned about a deterministic capture refusal (once per key). */
398
455
  const warnedRefusedCaptures = new Set<string>();
399
456
 
400
457
  /**
401
458
  * Warn once per key that the capture was REFUSED for a deterministic reason
402
- * (identity-guard trip or a rejected bake-lane loader). Distinct from
403
- * warnNullCaptureOnce: these are not cold-start shapes, the retry is skipped,
404
- * and the message carries the concrete cause instead of a differential.
459
+ * (identity-guard trip, a rejected or signal-settled bake-lane loader, a store
460
+ * refusal). Distinct from warnNullCaptureOnce: these are not cold-start
461
+ * shapes, the retry is skipped, and the message carries the concrete cause
462
+ * instead of a differential. Lane-dependent causes pass PPR_LANE_HINT as the
463
+ * trailing pointer; the rest keep the plain skill pointer.
405
464
  */
406
- function warnCaptureRefusedOnce(key: string, reason: string): void {
465
+ function warnCaptureRefusedOnce(
466
+ key: string,
467
+ reason: string,
468
+ pointer: string = "See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
469
+ ): void {
407
470
  if (warnedRefusedCaptures.has(key)) return;
408
471
  warnedRefusedCaptures.add(key);
409
472
  console.warn(
410
473
  `[rango] Shell capture for "${key}" was refused: ${reason}\n` +
411
474
  "The route stays on MISS (axis 1) — the page keeps working, only the shell " +
412
- "cache is off. See the /ppr skill " +
413
- "(node_modules/@rangojs/router/skills/ppr/SKILL.md) and " +
414
- "docs/design/loader-container-bake.md.",
475
+ `cache is off. ${pointer}`,
476
+ );
477
+ }
478
+
479
+ /**
480
+ * Refuse the capture when a capture guard tripped: a request-scoped read
481
+ * (cookies(), headers(), a { cache: false } variable) or ctx.dynamic() made
482
+ * anywhere the capture waited for — a handler, a promise it passes or pushes,
483
+ * an async server component, a bake-lane loader. The read throws where it
484
+ * happens, but the code can catch that throw, so the flag the guard sets on
485
+ * the capture context is what decides (server/context.ts
486
+ * assertNotInsideShellCapture). Returns true when it refused (and warned).
487
+ */
488
+ function refuseOnCaptureGuard(
489
+ key: string,
490
+ derivedCtx: RequestContext<any>,
491
+ ): boolean {
492
+ if (derivedCtx._dynamic) {
493
+ // A build-time capture reports dynamic() as its own outcome
494
+ // (build-shell-capture.ts): the URL keeps runtime capture.
495
+ if (derivedCtx.build) return true;
496
+ warnCaptureRefusedOnce(
497
+ key,
498
+ "ctx.dynamic() was called while capturing the shared shell (in a handler, or " +
499
+ "in a promise or async component the capture waited for). A dynamic render " +
500
+ "has no shell; the route keeps serving axis 1 for requests that opt out.",
501
+ );
502
+ return true;
503
+ }
504
+ const trip = derivedCtx._shellCaptureGuardTripped;
505
+ if (!trip) return false;
506
+ // Name the recorded source instead of hardcoding a lane: a trip can come
507
+ // from a bake-lane SEGMENT loader or from handler/render code, including a
508
+ // loader a handler awaits (issue #672). The guard recorded the read and its
509
+ // fix (server/context.ts tripShellCaptureGuard): cookies()/headers(), a
510
+ // { cache: false } variable, and the theme reads (#971).
511
+ const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
512
+ const origin = loaderId
513
+ ? `the loader "${loaderId}"`
514
+ : "handler/render code (no loader body was executing)";
515
+ warnCaptureRefusedOnce(
516
+ key,
517
+ `${origin} read ${trip.surface} during capture; request-scoped data must not ` +
518
+ `bake into the shared shell. ${trip.fix}`,
519
+ PPR_LANE_HINT,
415
520
  );
521
+ return true;
416
522
  }
417
523
 
524
+ /** The no-shell cause for handler output that missed the capture deadline. */
525
+ const HANDLER_OUTPUT_TIMEOUT_REASON: string =
526
+ "the handler output (a promise it passes or pushes, or an async server " +
527
+ "component) did not settle within ppr.captureTimeout";
528
+
529
+ /** The refusal for a capture that stored no doc record (a HIT needs it). */
530
+ const NO_DOC_RECORD_REASON: string =
531
+ "the capture produced no doc segment record, so a HIT could not replay the " +
532
+ "handler layer. Either the route's cache() refused the write (cache(false), or " +
533
+ "a condition() that returned false for the capture), or rendering the handler " +
534
+ "output failed (see the cache-write error reported for this key).";
535
+
536
+ /**
537
+ * Default limit (bytes) on a whole stored shell entry — prelude, postponed
538
+ * state, and snapshot — when the store declares none
539
+ * (SegmentCacheStore.maxShellEntryBytes). Cloudflare KV's 25 MiB value limit:
540
+ * the tightest common store tier a shell entry lands in.
541
+ */
542
+ export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
543
+
418
544
  /**
419
545
  * Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
420
546
  * inside a shell entry, when the route's `ppr` option does not set
@@ -424,8 +550,9 @@ function warnCaptureRefusedOnce(key: string, reason: string): void {
424
550
  * while still fitting any sane pinned-ring payload. Applied ONLY in
425
551
  * captureAndStoreShell (the single defaulting site — resolvePprConfig passes
426
552
  * 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).
553
+ * the same policy. The doc record is exempt; over the cap the other records
554
+ * are dropped (shell still stored and replayed; the pins' loaders read the
555
+ * live store — see PartialPrerenderProps.maxSnapshotBytes).
429
556
  */
430
557
  export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
431
558
 
@@ -436,12 +563,13 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
436
563
  const warnedOverCapSnapshots = new Set<string>();
437
564
 
438
565
  /**
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.
566
+ * Warn once per key that the capture's loader pins exceeded the route's
567
+ * `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
568
+ * with its doc record and every HIT replays the handler layer; only the
569
+ * pinned loaders lose their captured values, so their cached data can drift
570
+ * from the frozen prelude and hydration repairs it client-side. Once per
571
+ * key: the same page recaptures on every TTL roll and would otherwise
572
+ * re-warn forever.
445
573
  */
446
574
  function warnSnapshotOverCapOnce(
447
575
  key: string,
@@ -451,14 +579,15 @@ function warnSnapshotOverCapOnce(
451
579
  if (warnedOverCapSnapshots.has(key)) return;
452
580
  warnedOverCapSnapshots.add(key);
453
581
  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.",
582
+ `[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
583
+ `loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
584
+ "the shell was stored with its doc record only. HITs still replay the " +
585
+ "handler layer, but ssr: false loaders now read the live store: if " +
586
+ "their data drifts before the shell's TTL, hydration repairs the " +
587
+ "mismatch client-side. Raise the cap via the route's ppr option " +
588
+ "({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
589
+ "(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
590
+ "loaders read.",
462
591
  );
463
592
  }
464
593
 
@@ -479,8 +608,8 @@ const warnedBakeCosts = new Set<string>();
479
608
 
480
609
  /**
481
610
  * 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.
611
+ * in preference order. Handler output and bake-lane values are shell material
612
+ * by design, so this is a cost report with an exit, not a deprecation.
484
613
  */
485
614
  function warnBakeCostOnce(key: string, source: string, ms: number): void {
486
615
  if (warnedBakeCosts.has(key)) return;
@@ -489,13 +618,12 @@ function warnBakeCostOnce(key: string, source: string, ms: number): void {
489
618
  `[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
490
619
  "settle before the shell could freeze. This cost recurs on every capture " +
491
620
  "of the route and occupies the per-isolate capture queue; the served " +
492
- "response never shows it. Top-level promises BAKE by design (the " +
493
- "container settles; nested promises stay live). To make the value " +
494
- "per-request instead, nest it ({ data: promise }) and read it with use() " +
495
- "under Suspense; to keep it baked but cheap, wrap the work in cache() " +
496
- "(the capture replays the cached value); for a segment loader, add a " +
497
- "loading() boundary to its entry (live lane, masked at capture). See the " +
498
- "/ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
621
+ "response never shows it. To keep it baked but cheap, wrap the work in " +
622
+ 'cache() or "use cache"; to make it per-request, load it in a live ' +
623
+ "loader (no ssr: false) read under loading() or an inline <Suspense>; for " +
624
+ "a loader(Def, { ssr: false }), return the slow part as a nested promise " +
625
+ "or drop ssr: false. " +
626
+ PPR_LANE_HINT,
499
627
  );
500
628
  }
501
629
 
@@ -511,10 +639,13 @@ export interface ShellCaptureDebugEvent {
511
639
  key: string;
512
640
  /**
513
641
  * What happened:
514
- * - stored / redirect / no-shell / refused: one capture ATTEMPT's outcome
515
- * (see CaptureAttemptOutcome for the semantics of each)
642
+ * - stored / redirect / no-shell / refused / expired: one capture
643
+ * ATTEMPT's outcome (see CaptureAttemptOutcome for the semantics of each)
516
644
  * - error: the capture task failed with a genuine error (also routed through
517
645
  * reportCacheError; the key is backed off)
646
+ * - skip-stored: a capture for the key stored after the scheduling request
647
+ * read the store and missed (ShellCaptureDescriptor.storedSeqAtRead), so
648
+ * the shell that request missed exists now
518
649
  * - skip-in-flight: scheduleShellCapture found a capture already running for
519
650
  * the key (stampede guard) and scheduled nothing
520
651
  * - skip-backoff: the key is inside its refused-capture backoff window and
@@ -537,8 +668,10 @@ export interface ShellCaptureDebugEvent {
537
668
  | "redirect"
538
669
  | "no-shell"
539
670
  | "refused"
671
+ | "expired"
540
672
  | "error"
541
673
  | "skip-in-flight"
674
+ | "skip-stored"
542
675
  | "skip-backoff"
543
676
  | "skip-capacity"
544
677
  | "skip-inert-store"
@@ -590,6 +723,14 @@ export interface ShellCaptureDebugEvent {
590
723
  * it attributable. Absent when nothing held the gate.
591
724
  */
592
725
  bakeWaitMs?: number;
726
+ /**
727
+ * How long the capture waited for its doc record: the handler layer's
728
+ * promises, async server components and handle pushes settling, then the
729
+ * record's encode (settleCaptureRecord). Part of the one capture deadline.
730
+ */
731
+ recordSettleMs?: number;
732
+ /** Bytes of the stored entry: prelude, postponed state, and snapshot. */
733
+ entryBytes?: number;
593
734
  }
594
735
 
595
736
  /**
@@ -648,6 +789,12 @@ export function describeShellCaptureEvent(
648
789
  if (event.bakeWaitMs !== undefined) {
649
790
  parts.push(`bake=${event.bakeWaitMs}ms`);
650
791
  }
792
+ if (event.recordSettleMs !== undefined) {
793
+ parts.push(`record=${event.recordSettleMs}ms`);
794
+ }
795
+ if (event.entryBytes !== undefined) {
796
+ parts.push(`entry=${event.entryBytes}b`);
797
+ }
651
798
  return parts.join(" ");
652
799
  }
653
800
 
@@ -683,6 +830,7 @@ const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
683
830
  "redirect",
684
831
  "no-shell",
685
832
  "refused",
833
+ "expired",
686
834
  "error",
687
835
  ]);
688
836
 
@@ -976,6 +1124,33 @@ export interface ShellCaptureDescriptor {
976
1124
  debugSink?: (event: ShellCaptureDebugEvent) => void;
977
1125
  /** Store the snapshot for navigation replay, never the captured HTML prelude. */
978
1126
  navigationOnly?: true;
1127
+ /**
1128
+ * lastStoredCaptureSeq(key) when a document request read the store and
1129
+ * missed. The request schedules its capture only after rendering the MISS,
1130
+ * so a capture another request started can store in between; a later
1131
+ * sequence number then skips the redundant one (`skip-stored`), which would
1132
+ * re-render the page and drop the isolate's memo of the shell just stored.
1133
+ */
1134
+ storedSeqAtRead?: number;
1135
+ }
1136
+
1137
+ /** Per key, the sequence number of its latest stored capture (bounded). */
1138
+ const storedCaptureSeqs = new Map<string, number>();
1139
+ const STORED_CAPTURE_SEQS_MAX = 1_000;
1140
+ let storedCaptureSeq = 0;
1141
+
1142
+ /** The latest stored capture's sequence number for `key` in this isolate, or 0. */
1143
+ export function lastStoredCaptureSeq(key: string): number {
1144
+ return storedCaptureSeqs.get(key) ?? 0;
1145
+ }
1146
+
1147
+ function noteCaptureStored(key: string): void {
1148
+ storedCaptureSeqs.delete(key);
1149
+ if (storedCaptureSeqs.size >= STORED_CAPTURE_SEQS_MAX) {
1150
+ const oldest = storedCaptureSeqs.keys().next().value;
1151
+ if (oldest !== undefined) storedCaptureSeqs.delete(oldest);
1152
+ }
1153
+ storedCaptureSeqs.set(key, ++storedCaptureSeq);
979
1154
  }
980
1155
 
981
1156
  /**
@@ -1013,6 +1188,13 @@ export function scheduleShellCapture(
1013
1188
  // new token, and the stale task's token-guarded release can no longer
1014
1189
  // touch it.
1015
1190
  }
1191
+ if (
1192
+ descriptor.storedSeqAtRead !== undefined &&
1193
+ lastStoredCaptureSeq(key) > descriptor.storedSeqAtRead
1194
+ ) {
1195
+ publishCaptureDebugEvent(descriptor, { key, outcome: "skip-stored" });
1196
+ return;
1197
+ }
1016
1198
  // Refused/failed within the window → skip the doomed re-render (one probe per
1017
1199
  // key per window per isolate). Expired entries self-evict inside the check.
1018
1200
  if (isCaptureBackedOff(key)) {
@@ -1078,9 +1260,12 @@ export function scheduleShellCapture(
1078
1260
  // Update the negative cache off the terminal outcome. A stored shell clears
1079
1261
  // any prior backoff; a `no-shell` (after the in-place retry) backs the key
1080
1262
  // off so the next requests don't re-probe it. A `redirect` has no shell but
1081
- // is not a doomed render — leave the backoff untouched.
1082
- if (outcome === "stored") clearCaptureBackoff(key);
1083
- else if (outcome === "no-shell") {
1263
+ // is not a doomed render, and an `expired` retry lost a second record to
1264
+ // the same race — leave the backoff untouched.
1265
+ if (outcome === "stored") {
1266
+ clearCaptureBackoff(key);
1267
+ noteCaptureStored(key);
1268
+ } else if (outcome === "no-shell") {
1084
1269
  markCaptureBackoff(key);
1085
1270
  publishCaptureDebugEvent(descriptor, {
1086
1271
  key,
@@ -1193,6 +1378,91 @@ export function scheduleShellCapture(
1193
1378
  runBackground(reqCtx, serializedTask);
1194
1379
  }
1195
1380
 
1381
+ /**
1382
+ * The ttl/swr a shell is stored with: the route's ppr policy, capped by the
1383
+ * route cache() records the capture replayed or wrote
1384
+ * (RequestContext._routeRecordWindow). A HIT replays the capture's handler
1385
+ * output, while a client navigation reads the route's cache() tier first, so
1386
+ * a shell that outlived its record would show a document request other
1387
+ * handler output than a navigation of the same URL. Freshness and total
1388
+ * lifetime are capped separately, like the record's own SWR: the shell is
1389
+ * fresh while the record is (at most `ttl`), and servable while the record
1390
+ * is (at most `ttl + swr`), so a record already inside its swr window yields
1391
+ * a shell that is stale from the start and serves while it recaptures.
1392
+ *
1393
+ * Whole seconds, rounded up (the SegmentCacheStore.putShell contract), so the
1394
+ * shell may outlast the record by under a second. Null when the record's
1395
+ * total lifetime has run out: the shell would be dead on arrival.
1396
+ */
1397
+ export function capShellWindow(
1398
+ ttl: number,
1399
+ swr: number,
1400
+ record: Pick<RouteRecordWindow, "freshUntil" | "staleUntil">,
1401
+ now: number,
1402
+ ): { ttl: number; swr: number } | null {
1403
+ const total = Math.min(ttl + swr, (record.staleUntil - now) / 1000);
1404
+ if (!(total > 0)) return null;
1405
+ // At most `total`: freshUntil <= staleUntil and swr >= 0.
1406
+ const fresh = Math.max(0, Math.min(ttl, (record.freshUntil - now) / 1000));
1407
+ const wholeTtl = Math.ceil(fresh);
1408
+ return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
1409
+ }
1410
+
1411
+ /** Routes already warned that their cache() entry expires before a capture. */
1412
+ const warnedExpiredRecordRoutes = new Set<string>();
1413
+
1414
+ /**
1415
+ * Warn once per route (dev and production) that no shell was stored because
1416
+ * the route cache() entry ran out before the capture could store one
1417
+ * (capShellWindow returned null) where a retry cannot help: the capture
1418
+ * wrote the entry itself (`written`), or the in-place retry's entry ran out
1419
+ * too, or no budget was left to retry. The key backs off like a refused
1420
+ * capture.
1421
+ */
1422
+ function warnRecordRanOutOnce(
1423
+ route: string,
1424
+ record: RouteRecordWindow,
1425
+ detail: string,
1426
+ ): void {
1427
+ if (warnedExpiredRecordRoutes.has(route)) return;
1428
+ warnedExpiredRecordRoutes.add(route);
1429
+ console.warn(
1430
+ `[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
1431
+ `${record.swr}) ran out before the shell capture could store a shell ` +
1432
+ `(${detail}), so no shell is stored and the route renders without ` +
1433
+ "one. A shell never outlives the route cache() entry it replays: give " +
1434
+ "that cache() a ttl or swr longer than the capture takes, queue wait " +
1435
+ "included.",
1436
+ );
1437
+ }
1438
+
1439
+ /**
1440
+ * The record an `expired` attempt ran out on, per key, for the warning
1441
+ * runShellCapture prints when that outcome is terminal. Set and consumed
1442
+ * within one capture task.
1443
+ */
1444
+ const expiredRecords = new Map<
1445
+ string,
1446
+ { route: string; record: RouteRecordWindow; captureMs: number }
1447
+ >();
1448
+
1449
+ /**
1450
+ * A terminal `expired`: warn once per route with the record it ran out on,
1451
+ * and return `no-shell` so the caller backs the key off.
1452
+ */
1453
+ function expiredTerminal(key: string, reason: string): "no-shell" {
1454
+ const expired = expiredRecords.get(key);
1455
+ expiredRecords.delete(key);
1456
+ if (expired) {
1457
+ warnRecordRanOutOnce(
1458
+ expired.route,
1459
+ expired.record,
1460
+ `${reason}; the last attempt took ${expired.captureMs} ms`,
1461
+ );
1462
+ }
1463
+ return "no-shell";
1464
+ }
1465
+
1196
1466
  /**
1197
1467
  * The outcome of one capture attempt.
1198
1468
  * - `stored`: a usable shell was captured (and a putShell was attempted; a store
@@ -1200,10 +1470,21 @@ export function scheduleShellCapture(
1200
1470
  * the capture itself worked).
1201
1471
  * - `redirect`: the matched route redirects, so there is no shell to capture.
1202
1472
  * - `no-shell`: captureShellHTML returned null because the prelude was unusable
1203
- * or its private capture abort landed before the shell completed. This is the
1204
- * only RETRYABLE outcome.
1473
+ * or its private capture abort landed before the shell completed, or the
1474
+ * capture ran out of ppr.captureTimeout. Retried once in place, when no
1475
+ * deadline ran out (noShellCauses).
1476
+ * - `expired`: the route cache() record the capture read (not wrote) ran out
1477
+ * before the store (capShellWindow null): it was near its end (its age,
1478
+ * queue wait). Retried once in place, where a fresh match reads or renders
1479
+ * a newer record; a second `expired`, or no hard-cap budget left for the
1480
+ * retry, is terminal `no-shell` (backoff, warned once per route).
1205
1481
  */
1206
- type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
1482
+ type CaptureAttemptOutcome =
1483
+ | "stored"
1484
+ | "redirect"
1485
+ | "no-shell"
1486
+ | "refused"
1487
+ | "expired";
1207
1488
 
1208
1489
  function createNavigationCaptureRequest(request: Request, url: URL): Request {
1209
1490
  const headers = new Headers(request.headers);
@@ -1238,6 +1519,8 @@ type CaptureAttemptStats = Pick<
1238
1519
  | "untaggedBake"
1239
1520
  | "storeWrite"
1240
1521
  | "bakeWaitMs"
1522
+ | "recordSettleMs"
1523
+ | "entryBytes"
1241
1524
  >;
1242
1525
 
1243
1526
  /**
@@ -1251,8 +1534,9 @@ type CaptureAttemptStats = Pick<
1251
1534
  * when we froze the shell; the attempt itself warmed the module graph, so a second
1252
1535
  * attempt a short beat later usually completes the shell in the SAME background
1253
1536
  * task. That kills the old multi-request warmup where the caller had to re-issue
1254
- * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`; a
1255
- * genuine render error is NOT retried — it propagates to scheduleShellCapture's
1537
+ * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
1538
+ * that did not run out of ppr.captureTimeout (noShellCauses); a genuine render
1539
+ * error is NOT retried — it propagates to scheduleShellCapture's
1256
1540
  * reportCacheError. See docs/design/ppr-shell-resume.md.
1257
1541
  *
1258
1542
  * `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
@@ -1306,49 +1590,257 @@ async function runShellCapture(
1306
1590
  return outcome;
1307
1591
  };
1308
1592
 
1593
+ const taskStartedAt = performance.now();
1309
1594
  const first = await timedAttempt(1);
1310
1595
  // "refused" is deterministic (identity guard / rejected bake-lane loader —
1311
1596
  // its own warning already fired): no retry, and the caller backs the key off
1312
1597
  // exactly like a structural no-shell.
1313
1598
  if (first === "refused") return "no-shell";
1314
- // "stored" (success) or "redirect" (no shell exists): nothing to retry.
1315
- if (first !== "no-shell") return first;
1316
-
1317
- // Attempt 1 produced no usable shell. Retry ONCE in place — the first attempt
1318
- // warmed the dev transform graph / cold worker, so attempt 2 typically completes
1319
- // the shell without another HTTP request. The concise line is gated on the
1320
- // middleware's debug flag (threaded via the descriptor) so it replaces the old
1321
- // full DOMException dump with one readable breadcrumb.
1322
- log(
1323
- `[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
1324
- );
1325
- await delay(retryDelayMs);
1326
- const second = await timedAttempt(2);
1599
+ let second: CaptureAttemptOutcome;
1600
+ if (first === "expired") {
1601
+ // The record attempt 1 read ran out mid-capture: a fresh match reads or
1602
+ // renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
1603
+ // so a retry that could not finish inside it (barrier + captureTimeout)
1604
+ // is not started.
1605
+ const retryBudgetMs =
1606
+ SHELL_CAPTURE_TASK_HARD_CAP_MS - (performance.now() - taskStartedAt);
1607
+ const retryNeedsMs =
1608
+ SHELL_CAPTURE_WRITE_BARRIER_MS +
1609
+ (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1610
+ if (retryBudgetMs < retryNeedsMs) {
1611
+ return expiredTerminal(descriptor.key, "no time was left to retry");
1612
+ }
1613
+ log(
1614
+ `[ShellCache] capture attempt 1/2 for ${descriptor.key} outlived the route cache() record it read — retrying`,
1615
+ );
1616
+ second = await timedAttempt(2);
1617
+ if (second === "expired") {
1618
+ return expiredTerminal(descriptor.key, "twice in a row");
1619
+ }
1620
+ expiredRecords.delete(descriptor.key);
1621
+ } else {
1622
+ // "stored" (success) or "redirect" (no shell exists): nothing to retry.
1623
+ if (first !== "no-shell") return first;
1624
+ // Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
1625
+ // handlers may still be running (a match is not cancellable), and a retry
1626
+ // would start the same work beside them. A cold-module abort, the retry's
1627
+ // reason, ends well inside the deadline.
1628
+ if (noShellCauses.has(descriptor.key)) {
1629
+ warnNullCaptureOnce(descriptor.key, false);
1630
+ return "no-shell";
1631
+ }
1632
+
1633
+ // Attempt 1 produced no usable shell. Retry ONCE in place — the first
1634
+ // attempt warmed the dev transform graph / cold worker, so attempt 2
1635
+ // typically completes the shell without another HTTP request. The concise
1636
+ // line is gated on the middleware's debug flag (threaded via the
1637
+ // descriptor) so it replaces the old full DOMException dump with one
1638
+ // readable breadcrumb.
1639
+ log(
1640
+ `[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
1641
+ );
1642
+ await delay(retryDelayMs);
1643
+ second = await timedAttempt(2);
1644
+ }
1327
1645
  if (second === "refused") return "no-shell";
1328
1646
  if (second !== "no-shell") return second;
1329
1647
 
1330
1648
  // Both attempts came back with no usable shell. Cold-start would have healed by
1331
- // now, so the eternal-MISS structural shape (a loader route without loading()) is
1332
- // the likely cause — warn once per key. Ordering matters: because the retry
1649
+ // now, so the eternal-MISS structural shape (a boundary-less live-loader read;
1650
+ // lane rule: see resolveLoaderData, loader-cache.ts) is the likely cause —
1651
+ // warn once per key. Ordering matters: because the retry
1333
1652
  // absorbs cold-start, cold-start routes almost never reach this warning. The
1334
1653
  // caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
1335
1654
  log(
1336
1655
  `[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
1337
1656
  );
1338
- warnNullCaptureOnce(descriptor.key);
1657
+ warnNullCaptureOnce(descriptor.key, true);
1339
1658
  return "no-shell";
1340
1659
  }
1341
1660
 
1342
- /** Fold the capture's handle-liveness record into the entry flag (true | undefined). */
1343
- function handlerLayerIsLive(
1344
- liveness: RequestContext["_shellCaptureHandleLiveness"],
1345
- ): true | undefined {
1346
- if (!liveness) return undefined;
1347
- return liveness.holes ||
1348
- liveness.pendingPushes > 0 ||
1349
- liveness.handlerInvokedLoader
1350
- ? true
1351
- : undefined;
1661
+ /**
1662
+ * Who reads the snapshot this capture stores besides its doc record
1663
+ * (docs/design/shell-entry-layout.md §2): partial replay alone for a
1664
+ * navigation-only entry ("segments"), the loaders a document HIT re-runs
1665
+ * otherwise ("loaders"). Undefined when the capture recorded no doc record,
1666
+ * which only a prerender-served capture stores (its HIT tail takes the handler
1667
+ * layer from the prerender store and reads nothing else from the snapshot
1668
+ * beyond the loader records, so nothing is pruned).
1669
+ */
1670
+ function snapshotReaders(
1671
+ capture: ShellCaptureDescriptor,
1672
+ snapshot: readonly ShellSnapshotRecord[],
1673
+ docKey: string | undefined,
1674
+ ): ShellSnapshotReaders | undefined {
1675
+ if (!hasDocRecord(snapshot, docKey)) return undefined;
1676
+ return capture.navigationOnly ? "segments" : "loaders";
1677
+ }
1678
+
1679
+ /**
1680
+ * What the record-first step produced ({@link settleCaptureRecord}):
1681
+ * - `record`: the doc record settled; `match` carries its fragments in place
1682
+ * of the handler layer's elements, so the capture's Flight payload is the
1683
+ * bytes every HIT replays.
1684
+ * - `prerender`: the prerender store supplied the handler layer (a
1685
+ * Prerender route); its HIT tail replays the same build-time segments, so
1686
+ * the capture renders the match as is.
1687
+ * - `timeout`: handler output did not settle within the capture budget.
1688
+ * - `refused`: a deterministic refusal (a capture guard tripped, the route
1689
+ * opted out, or the handler output failed); the warning already fired.
1690
+ */
1691
+ export type CaptureRecordOutcome =
1692
+ | { kind: "record" | "prerender"; match: MatchResult }
1693
+ | { kind: "timeout"; reason: string }
1694
+ | { kind: "refused" };
1695
+
1696
+ /**
1697
+ * Record-first capture: the doc record is both the settle signal and the
1698
+ * parity source.
1699
+ *
1700
+ * Writing the record Flight-serializes every non-loader segment
1701
+ * (serializeSegments), which runs each handler's async server components and
1702
+ * waits for every promise the handler output carries — the capture's "is the
1703
+ * handler layer done" signal. Live-lane loader data is not in the record
1704
+ * (loader segments are excluded and their masked promises never settle), so
1705
+ * the wait never depends on a hole.
1706
+ *
1707
+ * The capture then renders its OWN Flight payload from the record's
1708
+ * fragments (fragmentSegments, the function a HIT tail uses) instead of from
1709
+ * the elements the match produced. Server components therefore render once
1710
+ * per capture, inside the record's encode: before this, the capture's Flight
1711
+ * render and the record's encode each ran them, so an uncached async
1712
+ * component showed one value in the prelude and another on every HIT.
1713
+ *
1714
+ * Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
1715
+ * top-level handle promises, the handler pushes' nested promises, and the
1716
+ * bake-lane loader containers settle first (the record encodes the handles
1717
+ * a HIT restores, and its handle encode has its own 5s timeout that must
1718
+ * never be the one that fires); then the capture's onResponse callbacks fire
1719
+ * with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
1720
+ * scope's own write), and the deferred writes settle.
1721
+ */
1722
+ export async function settleCaptureRecord(
1723
+ match: MatchResult,
1724
+ derivation: CaptureContextDerivation,
1725
+ capture: Pick<ShellCaptureDescriptor, "key">,
1726
+ deadline: number,
1727
+ ): Promise<CaptureRecordOutcome> {
1728
+ const { derivedCtx, freshHandleStore } = derivation;
1729
+ const recording = getRecordingStore(derivedCtx._cacheStore);
1730
+
1731
+ // cacheRoute waits for handleStore.settled, which needs the seal.
1732
+ freshHandleStore.seal();
1733
+ const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
1734
+ const settled = await withinDeadline(
1735
+ Promise.all([
1736
+ freshHandleStore.getData().then(resolveDeferredHandleValues),
1737
+ derivation.handlerPushesSettled(),
1738
+ loaderRecords && loaderRecords.size > 0
1739
+ ? Promise.allSettled([...loaderRecords.values()])
1740
+ : undefined,
1741
+ ]).catch(() => undefined),
1742
+ deadline,
1743
+ );
1744
+ // Past the deadline the record cannot settle in time: stop before its
1745
+ // write starts (no encode for a capture that already failed).
1746
+ if (!settled.done) {
1747
+ if (refuseOnCaptureGuard(capture.key, derivedCtx))
1748
+ return { kind: "refused" };
1749
+ return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1750
+ }
1751
+
1752
+ const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
1753
+ if (callbacks.length > 0) {
1754
+ const synthetic = new Response(null, { status: 200 });
1755
+ for (const cb of callbacks) {
1756
+ try {
1757
+ cb(synthetic);
1758
+ } catch {
1759
+ // A capture-time cache write that throws is degradation, not failure.
1760
+ }
1761
+ }
1762
+ }
1763
+ const drained = recording
1764
+ ? await recording.settleWrites(Math.max(0, deadline - Date.now()))
1765
+ : true;
1766
+
1767
+ // Capture guards first: a tripped guard usually also fails the record,
1768
+ // and its own message names the cause.
1769
+ if (refuseOnCaptureGuard(capture.key, derivedCtx)) return { kind: "refused" };
1770
+
1771
+ const docKey = derivedCtx._shellImplicitCache?.docKey;
1772
+ const record =
1773
+ recording && docKey !== undefined
1774
+ ? (recording.getRecord("segment", docKey) as CachedEntryData | undefined)
1775
+ : undefined;
1776
+ if (
1777
+ !record ||
1778
+ !Array.isArray(record.segments) ||
1779
+ record.segments.length === 0
1780
+ ) {
1781
+ // Post-match serve-source truth (cache-lookup.ts tryPrerenderLookup).
1782
+ if (derivedCtx._pprReplayPostMatchReason === "prerender-store") {
1783
+ return { kind: "prerender", match };
1784
+ }
1785
+ if (!drained) {
1786
+ return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1787
+ }
1788
+ warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
1789
+ return { kind: "refused" };
1790
+ }
1791
+
1792
+ // A handle encode that timed out stores "" (handle-snapshot.ts
1793
+ // encodeHandleValue). With every handler push settled above, that only
1794
+ // happens for a promise the walk does not reach (inside a Map, Set, or class
1795
+ // instance), which never settles in time on a retry either.
1796
+ if (!record.handles && recordSegmentsHaveHandles(record, freshHandleStore)) {
1797
+ warnCaptureRefusedOnce(
1798
+ capture.key,
1799
+ "a handle value the handler pushed did not finish encoding into the doc " +
1800
+ "record. A promise inside a Map, Set, or class instance is not awaited " +
1801
+ "before the encode; push plain objects and arrays.",
1802
+ );
1803
+ return { kind: "refused" };
1804
+ }
1805
+
1806
+ const { fragmentSegments } = await import("../cache/segment-codec.js");
1807
+ const fragments = new Map(
1808
+ (await fragmentSegments(record.segments)).map((s) => [s.id, s]),
1809
+ );
1810
+ return {
1811
+ kind: "record",
1812
+ match: {
1813
+ ...match,
1814
+ segments: match.segments.map((segment) => {
1815
+ const fragment =
1816
+ segment.type === "loader" ? undefined : fragments.get(segment.id);
1817
+ return fragment
1818
+ ? {
1819
+ ...segment,
1820
+ component: fragment.component,
1821
+ layout: fragment.layout,
1822
+ loading: fragment.loading,
1823
+ }
1824
+ : segment;
1825
+ }),
1826
+ },
1827
+ };
1828
+ }
1829
+
1830
+ /** Whether the handle store holds handler pushes for the record's segments. */
1831
+ function recordSegmentsHaveHandles(
1832
+ record: CachedEntryData,
1833
+ handleStore: HandleStore,
1834
+ ): boolean {
1835
+ for (const segment of record.segments) {
1836
+ const id = segment?.metadata?.id;
1837
+ if (id === undefined) continue;
1838
+ const data = handleStore.getDataForSegment(id, true);
1839
+ for (const name in data) {
1840
+ if (data[name]!.length > 0) return true;
1841
+ }
1842
+ }
1843
+ return false;
1352
1844
  }
1353
1845
 
1354
1846
  /**
@@ -1368,6 +1860,7 @@ function handlerLayerIsLive(
1368
1860
  * set a shell entry should be invalidatable by (loader tags belong to holes).
1369
1861
  * - _transitionWhen: a fresh [] so the capture's transition gating is its own.
1370
1862
  * - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
1863
+ * - _dynamic / dynamic(): an own latch the capture reads.
1371
1864
  * - _metricsStore: undefined so the capture never appends to the foreground's
1372
1865
  * (already-finalized) metrics.
1373
1866
  * - _renderBarrier family: an own barrier wired to the fresh handle store
@@ -1380,6 +1873,8 @@ function handlerLayerIsLive(
1380
1873
  * capture guard is load-bearing). Middleware is NOT re-run: it already ran for the
1381
1874
  * triggering request, and the derived context inherits its post-middleware state
1382
1875
  * (guarding is serve-time; the shell is never served without the full chain).
1876
+ * After the match, {@link settleCaptureRecord} writes the doc record and the
1877
+ * capture renders from it.
1383
1878
  *
1384
1879
  * A FRESH context (and match/render) per attempt is what makes the retry sound:
1385
1880
  * the second attempt is a clean capture, not a resumption of the first.
@@ -1407,23 +1902,67 @@ async function attemptCapture(
1407
1902
  await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
1408
1903
  stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
1409
1904
 
1410
- const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(
1905
+ const derivation = deriveShellCaptureContext(
1411
1906
  reqCtx,
1412
1907
  descriptor,
1413
1908
  descriptor.navigationOnly ? { request, url } : undefined,
1414
1909
  );
1910
+ const { derivedCtx, freshHandleStore } = derivation;
1415
1911
  const captureStartedAt = Date.now();
1416
1912
 
1417
1913
  return runWithRequestContext(derivedCtx, async () => {
1418
- const match = await ctx.router.match(request, { env });
1914
+ // One deadline for the whole capture (see
1915
+ // PartialPrerenderProps.captureTimeout): the match (handlers, and the
1916
+ // loaders they await), then the record, then the prerender with what is
1917
+ // left.
1918
+ const deadline =
1919
+ Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1920
+ const matched = await withinDeadline(
1921
+ ctx.router.match(request, { env }),
1922
+ deadline,
1923
+ );
1924
+ if (!matched.done) {
1925
+ noShellCauses.set(
1926
+ descriptor.key,
1927
+ "the handlers (or a loader a handler awaits) did not return within " +
1928
+ "ppr.captureTimeout",
1929
+ );
1930
+ return "no-shell";
1931
+ }
1932
+ const match = matched.value;
1419
1933
  // A route that redirects has no shell to capture — bail (no store write, no
1420
1934
  // retry: a redirect is deterministic).
1421
1935
  if (match.redirect) return "redirect";
1422
1936
 
1423
1937
  setRequestContextParams(match.params, match.routeName);
1424
1938
 
1425
- const payload = buildFullPayload(
1939
+ const settleStart = performance.now();
1940
+ const settled = await settleCaptureRecord(
1426
1941
  match,
1942
+ derivation,
1943
+ descriptor,
1944
+ deadline,
1945
+ );
1946
+ stats.recordSettleMs = Math.round(performance.now() - settleStart);
1947
+ if (
1948
+ process.env.NODE_ENV !== "production" &&
1949
+ settled.kind === "record" &&
1950
+ stats.recordSettleMs >= SHELL_CAPTURE_BAKE_WARN_MS
1951
+ ) {
1952
+ warnBakeCostOnce(
1953
+ descriptor.key,
1954
+ "the handler output (promises it passes or pushes, async server components)",
1955
+ stats.recordSettleMs,
1956
+ );
1957
+ }
1958
+ if (settled.kind === "refused") return "refused";
1959
+ if (settled.kind === "timeout") {
1960
+ noShellCauses.set(descriptor.key, settled.reason);
1961
+ return "no-shell";
1962
+ }
1963
+
1964
+ const payload = buildFullPayload(
1965
+ settled.match,
1427
1966
  ctx,
1428
1967
  url,
1429
1968
  derivedCtx,
@@ -1447,16 +1986,56 @@ async function attemptCapture(
1447
1986
  // content (and tags propagated by async cache()/"use cache" reads) lands after
1448
1987
  // this synchronous construction point, so snapshotting here dropped it — the
1449
1988
  // shell-tag snapshot must sit behind the quiesce gate (issue #676).
1450
- return captureAndStoreShell(
1989
+ const outcome = await captureAndStoreShell(
1451
1990
  ssrModule,
1452
1991
  flightStage.stream,
1453
1992
  freshHandleStore,
1454
1993
  derivedCtx,
1455
- descriptor,
1994
+ {
1995
+ ...descriptor,
1996
+ captureTimeout: Math.max(1, deadline - Date.now()),
1997
+ },
1456
1998
  stats,
1457
1999
  captureStartedAt,
1458
2000
  );
2001
+ if (outcome === "no-shell" && Date.now() >= deadline) {
2002
+ noShellCauses.set(
2003
+ descriptor.key,
2004
+ "the shell render did not finish within what ppr.captureTimeout left " +
2005
+ `after the handler output settled (${stats.recordSettleMs}ms)`,
2006
+ );
2007
+ }
2008
+ return outcome;
2009
+ });
2010
+ }
2011
+
2012
+ /**
2013
+ * Race `work` against an absolute deadline, keeping its value. The losing
2014
+ * work is not cancelled (a router match has no abort signal); callers that
2015
+ * lose do not start it again (runShellCapture skips its in-place retry).
2016
+ */
2017
+ export async function withinDeadline<T>(
2018
+ work: Promise<T>,
2019
+ deadline: number,
2020
+ ): Promise<{ done: true; value: T } | { done: false }> {
2021
+ // The losing branch keeps running; a late rejection must not surface.
2022
+ work.catch(() => {});
2023
+ let timer: ReturnType<typeof setTimeout> | undefined;
2024
+ const expired = new Promise<{ done: false }>((resolve) => {
2025
+ timer = setTimeout(
2026
+ () => resolve({ done: false }),
2027
+ Math.max(0, deadline - Date.now()),
2028
+ );
2029
+ (timer as { unref?: () => void }).unref?.();
1459
2030
  });
2031
+ try {
2032
+ return await Promise.race([
2033
+ work.then((value) => ({ done: true as const, value })),
2034
+ expired,
2035
+ ]);
2036
+ } finally {
2037
+ if (timer) clearTimeout(timer);
2038
+ }
1460
2039
  }
1461
2040
 
1462
2041
  /**
@@ -1464,13 +2043,20 @@ async function attemptCapture(
1464
2043
  * shared by BOTH shell producers: the runtime background capture
1465
2044
  * (attemptCapture, producer A) and the build-time prerender shell capture
1466
2045
  * (prerender/build-shell-capture.ts, producer B — issue #699). One
1467
- * implementation so the capture semantics — the nested-thenable mask funnel,
1468
- * handler-liveness bookkeeping, snapshot recording, the implicit doc-cache
1469
- * scope — cannot drift between producers.
2046
+ * implementation so the capture semantics — the handle push funnel (handler
2047
+ * pushes settled, loader pushes masked), the capture guards, snapshot
2048
+ * recording, the implicit doc-cache scope — cannot drift between producers.
1470
2049
  */
1471
2050
  export interface CaptureContextDerivation {
1472
2051
  derivedCtx: RequestContext;
1473
2052
  freshHandleStore: HandleStore;
2053
+ /**
2054
+ * Resolves once every handler handle push has settled at any depth of
2055
+ * plain objects and arrays (top-level promise, then the promises nested
2056
+ * in what it resolved to). The record-first step awaits it before the doc
2057
+ * record encodes its handles, so that encode never races a pending value.
2058
+ */
2059
+ handlerPushesSettled: () => Promise<void>;
1474
2060
  }
1475
2061
 
1476
2062
  /**
@@ -1488,104 +2074,110 @@ export function deriveShellCaptureContext(
1488
2074
  ): CaptureContextDerivation {
1489
2075
  const freshHandleStore = createHandleStore();
1490
2076
  freshHandleStore.onError = reqCtx._handleStore.onError;
1491
- // Shape = liveness for handles, exactly as for bake-lane loader containers
1492
- // (mask-nested.ts): nested thenables in a pushed handle container are
1493
- // per-request by declaration, so the CAPTURE's copy masks them — the
1494
- // consuming boundary postpones as a hole regardless of settle timing,
1495
- // instead of a fast-settling nested value baking into the shared shell. A
1496
- // TOP-LEVEL promise push keeps its documented bake contract (awaited
1497
- // pre-SSR, gate held open for it), but the container it RESOLVES to gets
1498
- // the same nested masking. Wrapping THIS store's push is the single funnel:
1499
- // the store exists only for this capture attempt, so every push wrapper
1500
- // (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
1501
- // the foreground store is untouched.
1502
- // Shell fast path bookkeeping on the same funnel:
1503
- // - handleLiveness: a nested thenable in a push made OUTSIDE a DSL loader
1504
- // scope (attribution read synchronously at push time — handler bodies,
1505
- // handler-invoked ctx.use(loader) callbacks, defers) declares
1506
- // handler-layer per-request data. Its mask is a hole only a handler
1507
- // re-run can fill, so the entry must not serve handler-free
1508
- // (ShellCacheEntry.handlerLiveHoles). Still-pending top-level handler
1509
- // pushes at the putShell barrier count too — their liveness is unknowable.
1510
- // - loaderScopedPushValues: DSL-loader pushes re-run fresh on every HIT, so
1511
- // their captured values must NOT enter a segment record's handle snapshot
1512
- // (replay would duplicate the fresh push, and their masked nested
1513
- // promises would stall the Flight handle encode to its timeout). The set
1514
- // rides the derived context (_shellCaptureLoaderHandleValues) and is
1515
- // applied ONLY at the captureHandles cache-write call site — every other
1516
- // getDataForSegment consumer (the render-barrier snapshot, prerender)
1517
- // sees every push.
1518
- // EXCEPTION — bake-lane { ssr: false } loaders: they execute at capture
1519
- // and never re-run on a HIT, so neither exclusion rationale applies. The
1520
- // capture awaits them (fresh.ts), their pushes are in the captured HTML
1521
- // (<head> title/meta, useHandle echoes), and the stored snapshot MUST
1522
- // carry the same values or HIT hydration mismatches against its own
1523
- // prelude (server text present, client handle empty). Only their
1524
- // settled, thenable-free pushes qualify — a deferred (thenable) push or
1525
- // one with masked nested promises keeps the exclusion so the handle
1526
- // encode cannot stall on a never-resolving mask.
1527
- const handleLiveness = {
1528
- holes: false,
1529
- pendingPushes: 0,
1530
- handlerInvokedLoader: false,
2077
+ // The capture handle store's push is the single funnel every push wrapper
2078
+ // (setupLoaderAccess, createUseFunction, prerender) goes through; the store
2079
+ // exists only for this capture attempt, so the foreground store is
2080
+ // untouched. Two lanes:
2081
+ // - HANDLER pushes (outside a DSL loader scope: handler bodies, handler-
2082
+ // invoked ctx.use(loader) bodies, defers) are handler output, baked like
2083
+ // everything else the handler layer produces. They pass through
2084
+ // unmasked; settleHandlerPush tracks each one so the record-first step
2085
+ // (settleCaptureRecord) can wait for its promises, top-level and nested,
2086
+ // before the doc record encodes the handles a HIT replays. A route
2087
+ // cache() record's restore (handle-snapshot.ts restoreHandles) lands
2088
+ // here too: a value it restores through pushReplayed keeps that loader
2089
+ // as owner, so a HIT that runs the loader replaces it, not appends.
2090
+ // - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
2091
+ // loaders they await via ctx.use execute at capture (live loaders are
2092
+ // masked), their pushes are in the captured HTML (<head> title/meta,
2093
+ // useHandle echoes), and a HIT does not run a promise-free bake-lane
2094
+ // loader (loader-cache.ts), so the stored record carries them: each
2095
+ // settled, thenable-free push from a loader body keeps that loader as
2096
+ // `owner` (HandleStore.push), the record stores it
2097
+ // (CachedEntryData.handleOwners), and the HIT restores it through
2098
+ // pushReplayed; a loader that does run on the HIT (a promise-carrying
2099
+ // bake-lane loader, a live loader awaiting it) replaces its restored
2100
+ // copies in place. Nested thenables stay masked (the promise shape is
2101
+ // the liveness declaration, mask-nested.ts). A deferred (thenable) push,
2102
+ // one with masked nested promises, or one outside a loader body (a
2103
+ // loader-cache replay names its loader: pushReplayed below) stays
2104
+ // tagged (HandleStore.push loaderPush) so the
2105
+ // handle encode cannot stall on a never-resolving mask; only the
2106
+ // captureHandles cache-write call site excludes tagged pushes, and the
2107
+ // loader records then carry `runs: 1` so a HIT runs those bodies for
2108
+ // them.
2109
+ const handlerPushSettles: Promise<void>[] = [];
2110
+ // A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
2111
+ // route cache() record's owned values, restoreHandles) re-pushes a loader's
2112
+ // recorded values through pushReplayed, which calls push() outside any
2113
+ // loader body: name that loader for the funnel so the record keeps them
2114
+ // under it too.
2115
+ let replayOwner: string | undefined;
2116
+ const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
2117
+ freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
2118
+ replayOwner = loaderId;
2119
+ try {
2120
+ rawPushReplayed(handleName, segmentId, value, loaderId);
2121
+ } finally {
2122
+ replayOwner = undefined;
2123
+ }
1531
2124
  };
1532
- const loaderScopedPushValues = new WeakSet<object>();
1533
2125
  const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
1534
2126
  freshHandleStore.push = (
1535
2127
  handleName: string,
1536
2128
  segmentId: string,
1537
2129
  value: unknown,
1538
2130
  ) => {
1539
- const pushedInLoaderScope = isInsideLoaderScope();
1540
- // Single walk: the mask reports whether it masked any nested thenable
1541
- // (the liveness declaration) while building the capture copy.
2131
+ if (!isInsideLoaderScope()) {
2132
+ handlerPushSettles.push(settleNestedThenables(value));
2133
+ rawCapturePush(handleName, segmentId, value, false, replayOwner);
2134
+ return;
2135
+ }
1542
2136
  let maskedNestedThenable = false;
1543
- const maskWithLiveness = (v: unknown): unknown => {
2137
+ const mask = (v: unknown): unknown => {
1544
2138
  const report: MaskReport = { thenable: false };
1545
2139
  const masked = maskNestedContainerThenables(v, undefined, report);
1546
- if (report.thenable) {
1547
- maskedNestedThenable = true;
1548
- if (!pushedInLoaderScope) handleLiveness.holes = true;
1549
- }
2140
+ if (report.thenable) maskedNestedThenable = true;
1550
2141
  return masked;
1551
2142
  };
1552
2143
  let masked: unknown;
2144
+ let loaderPush = true;
2145
+ let owner: string | undefined;
1553
2146
  if (isThenable(value)) {
1554
- if (!pushedInLoaderScope) {
1555
- handleLiveness.pendingPushes++;
1556
- const settle = () => handleLiveness.pendingPushes--;
1557
- value.then(settle, settle);
1558
- }
1559
- masked = value.then(maskWithLiveness);
1560
- // Deferred (thenable) loader pushes always keep the exclusion — the
1561
- // bake-lane carve-out below is for settled values only.
1562
- if (pushedInLoaderScope) {
1563
- loaderScopedPushValues.add(masked as object);
1564
- }
2147
+ // Deferred (thenable) loader pushes always keep the tag — the
2148
+ // carve-out below is for settled values only.
2149
+ masked = value.then(mask);
1565
2150
  } else {
1566
- masked = maskWithLiveness(value);
1567
- if (
1568
- pushedInLoaderScope &&
1569
- typeof masked === "object" &&
1570
- masked !== null
1571
- ) {
1572
- // Bake-lane { ssr: false } loader pushes are shell material (see the
1573
- // funnel comment above): keep them IN the stored snapshot when
1574
- // settled and thenable-free; everything else stays excluded.
1575
- const bodyLoaderId = getCurrentLoaderBodyId();
1576
- const baked =
1577
- bodyLoaderId !== undefined &&
1578
- !maskedNestedThenable &&
1579
- derivedCtx._awaitBeforeFlushLoaderIds?.has(bodyLoaderId) === true;
1580
- if (!baked) {
1581
- loaderScopedPushValues.add(masked);
1582
- }
2151
+ masked = mask(value);
2152
+ if (!maskedNestedThenable) {
2153
+ // A settled, thenable-free push from a loader body is shell
2154
+ // material (see the funnel comment above): only bake-lane loaders
2155
+ // and the loaders they await run at capture, and a HIT does not run
2156
+ // a promise-free bake-lane loader (loader-cache.ts), so the record
2157
+ // keeps the push under the loader that made it.
2158
+ const bodyLoaderId = getCurrentLoaderBodyId() ?? replayOwner;
2159
+ loaderPush = bodyLoaderId === undefined;
2160
+ if (!loaderPush) owner = bodyLoaderId;
1583
2161
  }
1584
2162
  }
1585
- rawCapturePush(handleName, segmentId, masked);
2163
+ // A push the record cannot keep reaches a HIT only from a live run of
2164
+ // its loader, so the loader records ask for one (runs: 1).
2165
+ if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
2166
+ rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
1586
2167
  };
1587
2168
 
1588
- const derivedCtx: RequestContext = Object.create(reqCtx);
2169
+ // The capture reuses the record keys its request resolved (CacheScope
2170
+ // resolveKeyFrom), read through the prototype: the map must exist on the
2171
+ // request before the derivation, or the capture would start its own and
2172
+ // run key() under the capture guard.
2173
+ reqCtx._resolvedCacheKeys ??= new Map();
2174
+ const derivedCtx: RequestContext = Object.assign(Object.create(reqCtx), {
2175
+ _handleStore: freshHandleStore,
2176
+ // The capture notes the route records it reads or writes itself; the
2177
+ // foreground's (a partial request's `partial:` record, say) are not the
2178
+ // ones this shell is captured from.
2179
+ _routeRecordWindow: undefined,
2180
+ });
1589
2181
  if (identity) {
1590
2182
  derivedCtx.request = identity.request;
1591
2183
  derivedCtx.url = identity.url;
@@ -1593,7 +2185,6 @@ export function deriveShellCaptureContext(
1593
2185
  derivedCtx.pathname = identity.url.pathname;
1594
2186
  derivedCtx.searchParams = identity.url.searchParams;
1595
2187
  }
1596
- derivedCtx._handleStore = freshHandleStore;
1597
2188
  // Own render barrier, closure-bound to the derived ctx and the fresh store
1598
2189
  // (issue #684, plan 009). Without this every _renderBarrier* read fell
1599
2190
  // through the prototype to the foreground's ALREADY-RESOLVED barrier: a
@@ -1603,7 +2194,6 @@ export function deriveShellCaptureContext(
1603
2194
  // also resets _treeHasStreaming (recomputed for the capture's tree) and the
1604
2195
  // deadlock-guard fields as own properties.
1605
2196
  wireRenderBarrier(derivedCtx, freshHandleStore);
1606
- derivedCtx._shellCaptureLoaderHandleValues = loaderScopedPushValues;
1607
2197
  derivedCtx._requestTags = new Set<string>();
1608
2198
  // Own explicit-store registry: cache-store resolutions during the capture
1609
2199
  // (the implicit scope's SnapshotOnlySegmentStore, any per-capture explicit
@@ -1614,7 +2204,20 @@ export function deriveShellCaptureContext(
1614
2204
  // context; module-singleton stores stay registered by normal renders.
1615
2205
  derivedCtx._explicitTaggedStores = new Set();
1616
2206
  derivedCtx._transitionWhen = [];
2207
+ // Own list: the capture's render errors refuse the capture only, and the
2208
+ // foreground's never reach it.
2209
+ derivedCtx._renderErrors = [];
1617
2210
  derivedCtx._shellCaptureRun = true;
2211
+ // Own dynamic() latch. The inherited method writes the FOREGROUND context
2212
+ // (its closure), which already served, and nothing read it for a runtime
2213
+ // capture: handler code that opts out after an await (a handler promise the
2214
+ // capture now waits for) must refuse THIS capture instead
2215
+ // (settleCaptureRecord and captureAndStoreShell read _dynamic). The ppr
2216
+ // header latch is left alone: a capture's response is never sent.
2217
+ derivedCtx._dynamic = false;
2218
+ derivedCtx.dynamic = (): void => {
2219
+ derivedCtx._dynamic = true;
2220
+ };
1618
2221
  // Own-property reset: an inherited flag would make serializeSegments store
1619
2222
  // a double-encoded fragment. No current arming site reaches a capture
1620
2223
  // render (kept as defense-in-depth for a future one).
@@ -1650,7 +2253,6 @@ export function deriveShellCaptureContext(
1650
2253
  // forwarding to the parent so the write persists and the worker stays alive),
1651
2254
  // then captureAndStoreShell awaits them before draining. Reads that HIT are
1652
2255
  // recorded synchronously during the render and need none of this.
1653
- derivedCtx._shellCaptureHandleLiveness = handleLiveness;
1654
2256
  if (reqCtx._cacheStore) {
1655
2257
  const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
1656
2258
  derivedCtx._cacheStore = recordingStore;
@@ -1659,12 +2261,13 @@ export function deriveShellCaptureContext(
1659
2261
  recordingStore.trackWrite(p);
1660
2262
  reqCtx.waitUntil(() => p);
1661
2263
  };
1662
- // Shell fast path (capture side): the implicit doc-cache scope makes the
2264
+ // The doc record (capture side): the implicit doc-cache scope makes the
1663
2265
  // capture's match write ALL matched non-loader segments as one doc-keyed
1664
2266
  // segment record — into the snapshot only (SnapshotOnlySegmentStore), so
1665
2267
  // the record dies with the shell entry and the next capture's lookup
1666
- // still misses (handlers re-run on recapture). Routes deriving their own
1667
- // cache scope are untouched (resolveShellImplicitCacheScope).
2268
+ // still misses (handlers re-run on recapture). A route deriving its own
2269
+ // cache() scope keeps it, and recordShellCaptureDocRecord (cache-store.ts)
2270
+ // writes the same record for it.
1668
2271
  derivedCtx._shellImplicitCache = {
1669
2272
  ttl: descriptor.ttl,
1670
2273
  swr: descriptor.swr,
@@ -1673,7 +2276,12 @@ export function deriveShellCaptureContext(
1673
2276
  };
1674
2277
  }
1675
2278
 
1676
- return { derivedCtx, freshHandleStore };
2279
+ return {
2280
+ derivedCtx,
2281
+ freshHandleStore,
2282
+ handlerPushesSettled: () =>
2283
+ Promise.all(handlerPushSettles).then(() => undefined),
2284
+ };
1677
2285
  }
1678
2286
 
1679
2287
  /**
@@ -1714,21 +2322,24 @@ async function captureAndStoreShell(
1714
2322
  // generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
1715
2323
  // sits above every boundary), the prelude comes back trivial, and
1716
2324
  // captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
1717
- // an error. This mirrors the __prerender_collect seal+settled regime, which also
1718
- // excludes loaders. See docs/design/ppr-shell-resume.md ("Loaders and handles").
2325
+ // an error. This mirrors the matchForPrerender (router/prerender-match.ts)
2326
+ // seal+settled regime, which also excludes loaders. See
2327
+ // docs/design/ppr-shell-resume.md ("Loaders and handles").
1719
2328
  handleStore.seal();
1720
2329
 
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).
2330
+ // TOP-LEVEL pushed handle promises are BAKED into the shell —
2331
+ // resolvedHandleStream awaits them before the payload's handles row emits. A
2332
+ // pushed promise with real latency would lose the byte-quiet race (the
2333
+ // pending handles row emits no bytes, the gate freezes, the row is dropped,
2334
+ // SsrRoot suspends at the root), so the gate is HELD open until the same
2335
+ // await completes: handlesBaked mirrors resolvedHandleStream's resolution
2336
+ // (getData waits the tracked-handler barrier; resolveDeferredHandleValues
2337
+ // awaits the top-level thenables). On the runtime path settleCaptureRecord
2338
+ // already waited for these (and for handler pushes' nested promises), so the
2339
+ // hold is normally released at once. A DSL loader push's nested promises are
2340
+ // masked and never hold the gate — they stay holes. Bounded by maxWaitMs like
2341
+ // every quiesce input (a defer hanging on a masked loader still ends in the
2342
+ // sanity-gate refusal).
1732
2343
  const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
1733
2344
  // Bake-lane loader containers hold the gate the same way (loader-container-
1734
2345
  // bake): a boundary-less container with real latency (a 100ms layout loader)
@@ -1774,36 +2385,32 @@ async function captureAndStoreShell(
1774
2385
  () => {},
1775
2386
  );
1776
2387
 
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 a shared shell. ` +
1798
- "For a segment loader (bake lane, no loading()): give its entry a loading() boundary " +
1799
- "(the live lane, masked at capture) or move the identity-dependent part into a nested " +
1800
- "promise. For handler/render code: keep the value live by consuming a loader " +
1801
- 'client-side (useLoader in a "use client" component). Note: `await ctx.use(loader)` ' +
1802
- "inside a HANDLER is exempt from this guard — its value bakes into the shared shell " +
1803
- "as a capture-time copy, mirroring cache() semantics (the consumption-lane rule).",
1804
- );
1805
- return "refused";
1806
- };
2388
+ // Deterministic capture-guard refusal (refuseOnCaptureGuard), checked at
2389
+ // BOTH exits below: the guard error either rejects the prerender itself
2390
+ // (boundary-less segment — lands in the catch) or is swallowed into
2391
+ // per-loader error UI (the render completes — caught after the try).
2392
+ const refuseOnGuardTrip = (): "refused" | undefined =>
2393
+ refuseOnCaptureGuard(capture.key, reqCtx) ? "refused" : undefined;
2394
+
2395
+ // Dev diagnostics for a no-shell attempt (warnNullCaptureOnce): the stacks of
2396
+ // the tasks React reports as still pending at the capture's abort. React's
2397
+ // componentStack is computed on read, so it is read only while there is room.
2398
+ let pendingStacks: string[] | undefined;
2399
+ let onAbortedTask:
2400
+ | ((errorInfo: { componentStack?: string } | undefined) => void)
2401
+ | undefined;
2402
+ if (process.env.NODE_ENV !== "production") {
2403
+ noShellPendingStacks.delete(capture.key);
2404
+ const stacks: string[] = [];
2405
+ pendingStacks = stacks;
2406
+ onAbortedTask = (errorInfo) => {
2407
+ if (stacks.length >= MAX_PENDING_STACKS) return;
2408
+ const componentStack = errorInfo?.componentStack;
2409
+ if (componentStack && !stacks.includes(componentStack)) {
2410
+ stacks.push(componentStack);
2411
+ }
2412
+ };
2413
+ }
1807
2414
 
1808
2415
  try {
1809
2416
  // captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
@@ -1819,6 +2426,10 @@ async function captureAndStoreShell(
1819
2426
  // static-part search reads bake what the key names.
1820
2427
  search: capture.searchSeed,
1821
2428
  origin: capture.originSeed,
2429
+ onError: (error) => {
2430
+ reqCtx._renderErrors?.push(error);
2431
+ },
2432
+ onAbortedTask,
1822
2433
  }),
1823
2434
  );
1824
2435
  } catch (error) {
@@ -1835,9 +2446,9 @@ async function captureAndStoreShell(
1835
2446
  // null = sanity gate refused (trivial/empty prelude, no <body>). Store nothing
1836
2447
  // and report `no-shell` so the caller (runShellCapture) can retry once and, if
1837
2448
  // that also fails, warn once per key. On a cold render this is the shell not
1838
- // yet finished; on a loader route WITHOUT a route-level loading() boundary it is
1839
- // the structural eternal-MISS shape (the masked loader pins the tree above
1840
- // <body> at tree-build). The caller's warning names both.
2449
+ // yet finished; a boundary-less live-loader read (lane rule: see
2450
+ // resolveLoaderData, loader-cache.ts) is the structural eternal-MISS shape.
2451
+ // The caller's warning names both.
1841
2452
  // Guard check first — BEFORE the trivial-prelude retry path. A guard trip
1842
2453
  // is deterministic (retrying re-trips it), and when the tripping loader's
1843
2454
  // error UI still completed a shell, storing it would bake the failure into
@@ -1846,6 +2457,7 @@ async function captureAndStoreShell(
1846
2457
  if (refused) return refused;
1847
2458
 
1848
2459
  if (result === null) {
2460
+ if (pendingStacks) noShellPendingStacks.set(capture.key, pendingStacks);
1849
2461
  return "no-shell";
1850
2462
  }
1851
2463
  if (stats) stats.preludeBytes = result.prelude.length;
@@ -1932,13 +2544,16 @@ async function captureAndStoreShell(
1932
2544
  let serializeContainer:
1933
2545
  | typeof import("../cache/segment-codec.js").serializeResult
1934
2546
  | undefined;
2547
+ // Capture-wide, read once: every record of this snapshot agrees.
2548
+ const runs: 0 | 1 = reqCtx._shellCaptureUnrecordedLoaderPush ? 1 : 0;
1935
2549
  for (const [segmentKey, containerPromise] of loaderRecords) {
1936
2550
  const elided = await elideLoaderContainer(containerPromise);
1937
2551
  if (elided.state === "rejected") {
1938
2552
  warnCaptureRefusedOnce(
1939
2553
  capture.key,
1940
2554
  `the loader for segment "${segmentKey}" rejected during capture; its error UI must not bake into the shared shell. ` +
1941
- "Fix the loader, or give its entry a loading() boundary so it stays on the live lane.",
2555
+ "Fix the loader, or drop its ssr: false to move it to the live lane.",
2556
+ PPR_LANE_HINT,
1942
2557
  );
1943
2558
  return "refused";
1944
2559
  }
@@ -1954,7 +2569,8 @@ async function captureAndStoreShell(
1954
2569
  warnCaptureRefusedOnce(
1955
2570
  capture.key,
1956
2571
  `the loader for segment "${segmentKey}" settled with redirect()/notFound() during capture; a request-specific signal must not bake into the shared shell. ` +
1957
- "Give its entry a loading() boundary so it stays on the live lane, or move the decision into middleware.",
2572
+ "Drop its ssr: false to move it to the live lane, or move the decision into middleware.",
2573
+ PPR_LANE_HINT,
1958
2574
  );
1959
2575
  return "refused";
1960
2576
  }
@@ -1966,7 +2582,14 @@ async function captureAndStoreShell(
1966
2582
  // must round-trip; serializeResult preserves it through Flight.
1967
2583
  serializeContainer ??= (await import("../cache/segment-codec.js"))
1968
2584
  .serializeResult;
1969
- const serialized = await serializeContainer(elided.value);
2585
+ // A value Flight cannot encode (an async component that throws, a
2586
+ // rejected promise the elide walk does not reach, a function, a
2587
+ // class instance) completes with an error row instead of rejecting
2588
+ // (issue #927). Collected with the render's errors, so the check
2589
+ // after this drain refuses the capture.
2590
+ const serialized = await serializeContainer(elided.value, (error) => {
2591
+ reqCtx._renderErrors?.push(error);
2592
+ });
1970
2593
  if (serialized !== null) {
1971
2594
  (snapshot ??= []).push({
1972
2595
  family: "loader",
@@ -1975,52 +2598,151 @@ async function captureAndStoreShell(
1975
2598
  // without rescanning whether the pin can resolve immediately
1976
2599
  // (holes: 0) or must wait for the fresh run's live promises
1977
2600
  // (holes: 1). See ShellSnapshotLoaderValue.
1978
- value: { value: serialized, holes: elided.hasHole ? 1 : 0 },
2601
+ value: {
2602
+ value: serialized,
2603
+ holes: elided.hasHole ? 1 : 0,
2604
+ runs,
2605
+ },
1979
2606
  });
1980
2607
  }
1981
2608
  } catch {
1982
- // Non-serializable container: leave it unpinned (it drifts on a HIT,
1983
- // the pre-snapshot behavior) rather than failing the capture.
2609
+ // Codec import failed: leave it unpinned (it drifts on a HIT, the
2610
+ // pre-snapshot behavior) rather than failing the capture.
2611
+ }
2612
+ }
2613
+ }
2614
+
2615
+ // A shell component that threw inside a Suspense boundary does not reject
2616
+ // the capture: Flight and Fizz report it through onError and the prelude
2617
+ // carries the errored boundary, which every HIT would serve (issue #915).
2618
+ // The drain's container encode adds its Flight errors here too (#927).
2619
+ // After the loader drain so a rejected bake-lane loader keeps its specific
2620
+ // refusal. Thrown like a fatal shell error: no retry, reportCacheError and
2621
+ // backoff in scheduleShellCapture.
2622
+ const renderErrors = reqCtx._renderErrors;
2623
+ if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
2624
+
2625
+ // Record only what a HIT reads (issue #941): the doc record, plus what
2626
+ // the bake-lane loaders a HIT runs read. Before the size guards, so they measure
2627
+ // what is stored.
2628
+ const docKey = reqCtx._shellImplicitCache?.docKey;
2629
+ let prunedRecords: string | undefined;
2630
+ if (recording && snapshot) {
2631
+ const readers = snapshotReaders(capture, snapshot, docKey);
2632
+ if (readers && docKey !== undefined) {
2633
+ const { kept, pruned } = pruneShellSnapshot(
2634
+ snapshot,
2635
+ readers,
2636
+ recording.loaderKeys,
2637
+ docKey,
2638
+ );
2639
+ if (pruned.length > 0) {
2640
+ prunedRecords = countSnapshotFamilies(pruned);
2641
+ snapshot = kept.length > 0 ? kept : undefined;
1984
2642
  }
1985
2643
  }
1986
2644
  }
1987
2645
 
1988
2646
  // Snapshot size guard (issue #651): the snapshot duplicates every pinned
1989
- // cache value inside the shell entry, so a page over a large cache()
1990
- // segment can push the stored envelope toward store value limits (KV caps
1991
- // a value at 25 MiB) with no signal — the kv.put rejects deep inside
1992
- // waitUntil. Measure the serialized snapshot (UTF-8 bytes of the JSON that
1993
- // rides in the envelope) AFTER the loader family is appended, and over the
1994
- // cap store the shell WITHOUT it: pinned reads then fall back to the live
1995
- // store on a HIT (documented drift — hydration repairs a mismatch
1996
- // client-side, the pre-snapshot behavior), which beats losing the whole
1997
- // entry to a store-side write rejection. Reported once per key.
2647
+ // cache value inside the shell entry. `maxSnapshotBytes` bounds the PINS
2648
+ // (loader-family records and the item/response records loaders read);
2649
+ // over it they are dropped and the entry keeps its doc record, so every
2650
+ // HIT still replays the handler layer (the pins' loaders then read the
2651
+ // live store: documented drift, repaired client-side). The doc record is
2652
+ // exempt: without it a HIT could not serve at all. The whole entry is
2653
+ // bounded separately below, by the store's value limit.
1998
2654
  if (snapshot && snapshot.length > 0) {
1999
- const snapshotBytes = SNAPSHOT_BYTE_ENCODER.encode(
2000
- JSON.stringify(snapshot),
2001
- ).length;
2002
- if (stats) stats.snapshotBytes = snapshotBytes;
2655
+ const docRecord =
2656
+ docKey !== undefined
2657
+ ? snapshot.find((r) => r.family === "segment" && r.key === docKey)
2658
+ : undefined;
2659
+ const pins = docRecord
2660
+ ? snapshot.filter((r) => r !== docRecord)
2661
+ : snapshot;
2662
+ const pinBytes =
2663
+ pins.length > 0
2664
+ ? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
2665
+ : 0;
2666
+ if (stats) stats.snapshotBytes = pinBytes;
2003
2667
  const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
2004
- if (snapshotBytes > cap) {
2005
- warnSnapshotOverCapOnce(capture.key, snapshotBytes, cap);
2006
- snapshot = undefined;
2668
+ if (pinBytes > cap) {
2669
+ warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
2670
+ snapshot = docRecord ? [docRecord] : undefined;
2007
2671
  if (stats) stats.snapshotSkipped = true;
2008
2672
  }
2009
2673
  }
2010
2674
 
2011
- // Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
2012
- // here the capture has quiesced and the deferred cache writes were awaited, so
2013
- // tags recorded AFTER an await in async shell content (and by async
2014
- // cache()/"use cache" reads propagating through recordRequestTags) are
2015
- // included — issue #676. Loaders are masked, so loader cache tags — which
2016
- // belong to the holes, not the shell — never execute during capture and cannot
2017
- // contribute. Union with the route's static ppr.tags (capture.tags); the
2018
- // collected set is authoritative, the option only adds what the render cannot
2019
- // know.
2020
- const collected = [...reqCtx._requestTags];
2675
+ // A capture that ran handlers must store its doc record: an entry without
2676
+ // it could only be served by running handlers after the commit. Only a
2677
+ // prerender-served capture (the prerender store supplies the handler
2678
+ // layer on every HIT) or a navigation-only entry may lack it.
2679
+ const storedDocKey = hasDocRecord(snapshot, docKey) ? docKey : undefined;
2680
+ if (
2681
+ storedDocKey === undefined &&
2682
+ !capture.navigationOnly &&
2683
+ reqCtx._pprReplayPostMatchReason !== "prerender-store"
2684
+ ) {
2685
+ warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
2686
+ return "refused";
2687
+ }
2688
+
2689
+ // Shell tags: what the shell renders from, and nothing else. The handler
2690
+ // layer is the doc record, whose tags are what its content recorded (the
2691
+ // handlers, the server components its serialization rendered, the loaders
2692
+ // they consumed, the handle values; cache-scope.ts collectRecordTags), or
2693
+ // what a replayed route cache() record carried (recordSegmentTags). So a
2694
+ // capture that rendered fresh and one that replayed the route's record
2695
+ // store the same tags. Bake-lane loaders add theirs (SHELL_BAKE_TAG_OWNER):
2696
+ // their data is in the shell, and no handler reads it. A live-lane loader
2697
+ // never runs at capture, and one whose cache() config tags are recorded
2698
+ // anyway is a hole's, so request-level tags are not the source. Read at
2699
+ // the write barrier: the record settled and the deferred writes were
2700
+ // awaited, so a tag recorded after an await (#676) is in. A capture
2701
+ // without a doc record (the prerender store served its match) keeps the
2702
+ // request-level set. Union with the route's static ppr.tags.
2703
+ const docRecordValue = storedDocKey
2704
+ ? (snapshot?.find((r) => r.family === "segment" && r.key === storedDocKey)
2705
+ ?.value as CachedEntryData | undefined)
2706
+ : undefined;
2707
+ const collected = docRecordValue
2708
+ ? [
2709
+ ...(docRecordValue.tags ?? []),
2710
+ ...getSegmentTags(reqCtx, SHELL_BAKE_TAG_OWNER),
2711
+ ]
2712
+ : [...reqCtx._requestTags];
2021
2713
  const union = new Set<string>([...(capture.tags ?? []), ...collected]);
2022
2714
  const shellTags = union.size > 0 ? [...union] : undefined;
2023
2715
 
2716
+ // Whole-entry guard: the prelude, postponed state, snapshot, and tags
2717
+ // travel in one stored value. Over the store's limit (Cloudflare KV:
2718
+ // 25 MiB) the write would fail inside waitUntil and every MISS would
2719
+ // recapture, so refuse here (the key backs off) instead. Measured as the
2720
+ // stores write it (estimateShellEntryBytes): the postponed state rides a
2721
+ // JSON head as an escaped string.
2722
+ const entryBytes = estimateShellEntryBytes({
2723
+ // A navigation-only entry stores no document half.
2724
+ preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
2725
+ postponed: capture.navigationOnly ? undefined : result.postponed,
2726
+ snapshot,
2727
+ tags: shellTags,
2728
+ docKey: storedDocKey,
2729
+ prunedRecords: snapshot ? prunedRecords : undefined,
2730
+ });
2731
+ if (stats) stats.entryBytes = entryBytes;
2732
+ const entryLimit =
2733
+ (capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
2734
+ DEFAULT_SHELL_ENTRY_MAX_BYTES;
2735
+ if (entryBytes > entryLimit) {
2736
+ warnCaptureRefusedOnce(
2737
+ capture.key,
2738
+ `the shell entry is ${entryBytes} bytes (prelude, postponed state, ` +
2739
+ `snapshot, and head), over the store's ${entryLimit}-byte value limit. ` +
2740
+ "Shrink the page the shell bakes, or move large regions under a live " +
2741
+ "loader's boundary.",
2742
+ );
2743
+ return "refused";
2744
+ }
2745
+
2024
2746
  // Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
2025
2747
  // that choice only to operators who enabled structured capture diagnostics.
2026
2748
  if (
@@ -2044,45 +2766,73 @@ async function captureAndStoreShell(
2044
2766
  ...(capture.navigationOnly
2045
2767
  ? {}
2046
2768
  : {
2047
- // slice() copies just this view's bytes into a fresh
2048
- // ArrayBuffer, so a prelude that is a subarray of a larger
2049
- // backing buffer encodes only its own region — bufferToBase64
2050
- // reads the whole ArrayBuffer it is handed.
2051
- prelude: bufferToBase64(
2052
- result.prelude.slice().buffer as ArrayBuffer,
2053
- ),
2769
+ // bytesToBase64 encodes the view's own region, so a prelude
2770
+ // that is a subarray of a larger buffer encodes correctly.
2771
+ prelude: bytesToBase64(result.prelude),
2054
2772
  postponed: result.postponed,
2055
2773
  }),
2056
2774
  reactVersion: React.version,
2057
2775
  buildVersion: capture.buildVersion,
2058
2776
  // The theme this capture's payload was built with (buildFullPayload
2059
- // reads reqCtx.theme off the derived context). The serve tail replays
2060
- // it so the resume tree matches the frozen prelude — see
2061
- // ShellCacheEntry.initialTheme.
2062
- initialTheme: reqCtx.theme,
2777
+ // reads payloadInitialTheme off the derived context: the no-cookie
2778
+ // default, #971). The serve tail replays it so the resume tree
2779
+ // matches the frozen prelude — see ShellCacheEntry.initialTheme.
2780
+ initialTheme: payloadInitialTheme(reqCtx),
2063
2781
  snapshot,
2782
+ prunedRecords: snapshot ? prunedRecords : undefined,
2064
2783
  // The canonical doc segment record's key, published by the doc
2065
- // scope's cacheRoute during this capture's match (undefined when no
2066
- // doc record was recorded — the entry then never claims navigation
2067
- // replayability). Only meaningful alongside a snapshot.
2068
- docKey: snapshot ? reqCtx._shellImplicitCache?.docKey : undefined,
2784
+ // scope's cacheRoute during this capture's match. Every HIT tail
2785
+ // looks the record up by this key (serveShellHit fixedDocKey);
2786
+ // undefined only for a prerender-served entry.
2787
+ docKey: storedDocKey,
2069
2788
  navigationOnly: capture.navigationOnly,
2070
- // Handler-layer liveness folded at the barrier: nested thenables in
2071
- // handler-scoped pushes, handler pushes still pending (liveness
2072
- // unknowable), or a handler-invoked loader execution — any of them
2073
- // refuses the FAST PATH, not the capture. See
2074
- // _shellCaptureHandleLiveness.
2075
- handlerLiveHoles: handlerLayerIsLive(
2076
- reqCtx._shellCaptureHandleLiveness,
2077
- ),
2078
- transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
2079
2789
  createdAt: captureStartedAt,
2080
2790
  };
2791
+ // Capped to the route record the capture used: the policy the
2792
+ // store would apply (resolveTtl/resolveSwrWindow, as putShell does),
2793
+ // made explicit.
2794
+ const record = reqCtx._routeRecordWindow;
2795
+ let window: { ttl?: number; swr?: number } = {
2796
+ ttl: capture.ttl,
2797
+ swr: capture.swr,
2798
+ };
2799
+ if (record) {
2800
+ const capped = capShellWindow(
2801
+ resolveTtl(capture.ttl, store.defaults, DEFAULT_FUNCTION_TTL),
2802
+ resolveSwrWindow(capture.swr, store.defaults),
2803
+ record,
2804
+ Date.now(),
2805
+ );
2806
+ if (!capped) {
2807
+ // Dead on arrival, not stored. A record this attempt wrote ran
2808
+ // out between its write and the store, which a retry repeats:
2809
+ // warn, refuse, back off. A record it read was near its end (its
2810
+ // age, queue wait): `expired` retries on a fresh match.
2811
+ const route = reqCtx._routeName ?? capture.key;
2812
+ if (record.written) {
2813
+ const sinceWriteMs =
2814
+ Date.now() - (record.freshUntil - record.ttl * 1000);
2815
+ warnRecordRanOutOnce(
2816
+ route,
2817
+ record,
2818
+ `${sinceWriteMs} ms after this capture wrote it`,
2819
+ );
2820
+ return "refused";
2821
+ }
2822
+ expiredRecords.set(capture.key, {
2823
+ route,
2824
+ record,
2825
+ captureMs: Date.now() - captureStartedAt,
2826
+ });
2827
+ return "expired";
2828
+ }
2829
+ window = capped;
2830
+ }
2081
2831
  const storeWrite = await store.putShell(
2082
2832
  capture.key,
2083
2833
  entry,
2084
- capture.ttl,
2085
- capture.swr,
2834
+ window.ttl,
2835
+ window.swr,
2086
2836
  shellTags,
2087
2837
  );
2088
2838
  if (stats && storeWrite) stats.storeWrite = storeWrite;
@@ -2155,3 +2905,25 @@ export {
2155
2905
  REFUSED_CAPTURE_MAX_MS,
2156
2906
  REFUSED_CAPTURE_DEV_MAX_MS,
2157
2907
  };
2908
+
2909
+ /**
2910
+ * @internal Reset this module's per-isolate state: the capture stampede
2911
+ * guard, the refused-capture backoff, the recorded no-shell causes (which
2912
+ * also skip a timed-out capture's in-place retry), the once-per-key warnings
2913
+ * and the buffered debug events. Tests only (testing/serve-shell-request.ts
2914
+ * resetShellTestState), between requests, never while a capture runs.
2915
+ */
2916
+ export function resetShellCaptureStateForTests(): void {
2917
+ inFlightCaptures.clear();
2918
+ refusedCaptures.clear();
2919
+ warnedNullCaptures.clear();
2920
+ noShellCauses.clear();
2921
+ noShellPendingStacks.clear();
2922
+ warnedRefusedCaptures.clear();
2923
+ warnedOverCapSnapshots.clear();
2924
+ warnedBakeCosts.clear();
2925
+ warnedExpiredRecordRoutes.clear();
2926
+ expiredRecords.clear();
2927
+ storedCaptureSeqs.clear();
2928
+ lastCaptureEventsForTiming.clear();
2929
+ }