@rangojs/router 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/dist/types/browser/event-controller.d.ts +3 -3
  2. package/dist/types/browser/history-state.d.ts +2 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  4. package/dist/types/browser/partial-update.d.ts +16 -1
  5. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  6. package/dist/types/browser/react/Link.d.ts +19 -9
  7. package/dist/types/browser/react/context.d.ts +2 -2
  8. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  9. package/dist/types/browser/react/location-state.d.ts +1 -1
  10. package/dist/types/browser/transition-when.d.ts +80 -0
  11. package/dist/types/browser/types.d.ts +104 -9
  12. package/dist/types/build/generate-manifest.d.ts +8 -0
  13. package/dist/types/cache/background-task.d.ts +24 -2
  14. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +44 -13
  16. package/dist/types/cache/cache-policy.d.ts +1 -1
  17. package/dist/types/cache/cache-runtime.d.ts +7 -0
  18. package/dist/types/cache/cache-scope.d.ts +117 -35
  19. package/dist/types/cache/cache-tag.d.ts +24 -4
  20. package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
  21. package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
  22. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
  23. package/dist/types/cache/document-cache.d.ts +10 -0
  24. package/dist/types/cache/handle-snapshot.d.ts +40 -8
  25. package/dist/types/cache/invalidation-order.d.ts +23 -0
  26. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +88 -141
  28. package/dist/types/cache/tag-invalidation.d.ts +38 -14
  29. package/dist/types/cache/types.d.ts +68 -61
  30. package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
  31. package/dist/types/client-urls/navigation.d.ts +28 -2
  32. package/dist/types/client-urls/server-projection.d.ts +5 -2
  33. package/dist/types/client-urls/types.d.ts +5 -5
  34. package/dist/types/client.d.ts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.rsc.d.ts +1 -1
  37. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  38. package/dist/types/route-name.d.ts +7 -0
  39. package/dist/types/router/error-handling.d.ts +1 -1
  40. package/dist/types/router/intercept-resolution.d.ts +2 -0
  41. package/dist/types/router/match-context.d.ts +5 -0
  42. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  43. package/dist/types/router/router-options.d.ts +4 -1
  44. package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
  45. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  46. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
  47. package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
  48. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  49. package/dist/types/router/transition-when.d.ts +24 -10
  50. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  51. package/dist/types/rsc/full-payload.d.ts +14 -7
  52. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  53. package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
  54. package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
  55. package/dist/types/rsc/shell-capture.d.ts +78 -81
  56. package/dist/types/rsc/shell-serve.d.ts +19 -48
  57. package/dist/types/rsc/types.d.ts +2 -0
  58. package/dist/types/segment-system.d.ts +7 -0
  59. package/dist/types/server/context.d.ts +89 -61
  60. package/dist/types/server/cookie-store.d.ts +30 -18
  61. package/dist/types/server/handle-store.d.ts +52 -1
  62. package/dist/types/server/request-context.d.ts +66 -45
  63. package/dist/types/server/request-headers.d.ts +44 -0
  64. package/dist/types/server.d.ts +1 -0
  65. package/dist/types/testing/e2e/index.d.ts +1 -1
  66. package/dist/types/testing/index.d.ts +3 -2
  67. package/dist/types/testing/internal/action-id.d.ts +7 -0
  68. package/dist/types/testing/location-state-key.d.ts +24 -0
  69. package/dist/types/testing/render-route.d.ts +17 -1
  70. package/dist/types/testing/run-transition-when.d.ts +44 -69
  71. package/dist/types/testing/serve-shell-request.d.ts +7 -6
  72. package/dist/types/testing/shell-status.d.ts +34 -22
  73. package/dist/types/transition-when-ref.d.ts +79 -0
  74. package/dist/types/types/cache-types.d.ts +15 -3
  75. package/dist/types/types/index.d.ts +1 -1
  76. package/dist/types/types/segments.d.ts +86 -71
  77. package/dist/types/urls/path-helper-types.d.ts +3 -4
  78. package/dist/types/urls/pattern-types.d.ts +3 -4
  79. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  80. package/dist/vite/index.js +960 -97
  81. package/package.json +1 -1
  82. package/skills/cache-guide/SKILL.md +76 -49
  83. package/skills/caching/SKILL.md +167 -69
  84. package/skills/client-urls/SKILL.md +1 -1
  85. package/skills/comparison/references/framework-comparison.md +8 -5
  86. package/skills/document-cache/SKILL.md +24 -0
  87. package/skills/hooks/SKILL.md +3 -1
  88. package/skills/hooks/navigation.md +23 -2
  89. package/skills/hooks/state.md +98 -6
  90. package/skills/intercept/SKILL.md +29 -12
  91. package/skills/loader/SKILL.md +52 -21
  92. package/skills/middleware/SKILL.md +18 -0
  93. package/skills/migrate-nextjs/SKILL.md +3 -3
  94. package/skills/observability/SKILL.md +6 -5
  95. package/skills/parallel/SKILL.md +1 -1
  96. package/skills/ppr/SKILL.md +129 -77
  97. package/skills/prerender/SKILL.md +3 -3
  98. package/skills/rango/SKILL.md +25 -6
  99. package/skills/route/SKILL.md +2 -0
  100. package/skills/testing/SKILL.md +27 -25
  101. package/skills/testing/cache-prerender.md +9 -9
  102. package/skills/testing/client-components.md +45 -3
  103. package/skills/testing/loader.md +3 -2
  104. package/skills/testing/middleware.md +1 -0
  105. package/skills/testing/predicates.md +49 -49
  106. package/skills/testing/render-handler.md +1 -0
  107. package/skills/testing/serve-shell-request.md +14 -0
  108. package/skills/testing/server-actions.md +9 -3
  109. package/skills/theme/SKILL.md +8 -4
  110. package/skills/typesafety/env-and-bindings.md +8 -0
  111. package/skills/use-cache/SKILL.md +64 -19
  112. package/skills/view-transitions/SKILL.md +150 -29
  113. package/src/__augment-tests__/augmented.check.ts +23 -0
  114. package/src/browser/event-controller.ts +4 -4
  115. package/src/browser/history-state.ts +52 -1
  116. package/src/browser/link-interceptor.ts +4 -0
  117. package/src/browser/navigation-bridge.ts +77 -18
  118. package/src/browser/navigation-client.ts +4 -3
  119. package/src/browser/navigation-store.ts +43 -1
  120. package/src/browser/navigation-transaction.ts +31 -11
  121. package/src/browser/partial-update.ts +146 -35
  122. package/src/browser/prefetch/cache.ts +6 -6
  123. package/src/browser/prefetch/fetch.ts +2 -2
  124. package/src/browser/react/Link.tsx +38 -33
  125. package/src/browser/react/NavigationProvider.tsx +2 -2
  126. package/src/browser/react/context.ts +2 -2
  127. package/src/browser/react/location-state-shared.ts +241 -49
  128. package/src/browser/react/location-state.ts +5 -2
  129. package/src/browser/rsc-router.tsx +4 -0
  130. package/src/browser/server-action-bridge.ts +84 -15
  131. package/src/browser/transition-when.ts +205 -0
  132. package/src/browser/types.ts +124 -10
  133. package/src/build/generate-manifest.ts +77 -1
  134. package/src/cache/background-task.ts +55 -2
  135. package/src/cache/cache-exec-scope.ts +48 -1
  136. package/src/cache/cache-key-utils.ts +56 -16
  137. package/src/cache/cache-policy.ts +9 -4
  138. package/src/cache/cache-runtime.ts +197 -144
  139. package/src/cache/cache-scope.ts +304 -112
  140. package/src/cache/cache-tag.ts +105 -13
  141. package/src/cache/cf/cf-cache-store.ts +152 -76
  142. package/src/cache/cf/cf-shell-frame.ts +2 -2
  143. package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
  144. package/src/cache/document-cache.ts +53 -7
  145. package/src/cache/handle-snapshot.ts +52 -9
  146. package/src/cache/invalidation-order.ts +84 -0
  147. package/src/cache/request-tag-mask.ts +147 -0
  148. package/src/cache/shell-snapshot.ts +130 -348
  149. package/src/cache/tag-invalidation.ts +57 -51
  150. package/src/cache/types.ts +71 -67
  151. package/src/cache/vercel/vercel-cache-store.ts +61 -58
  152. package/src/client-urls/client-root.tsx +8 -4
  153. package/src/client-urls/client-urls.ts +8 -6
  154. package/src/client-urls/navigation.ts +55 -4
  155. package/src/client-urls/server-projection.ts +31 -10
  156. package/src/client-urls/types.ts +12 -5
  157. package/src/client.tsx +2 -0
  158. package/src/host/cookie-handler.ts +2 -1
  159. package/src/index.rsc.ts +3 -0
  160. package/src/index.ts +3 -0
  161. package/src/loader-redirect.tsx +7 -3
  162. package/src/prerender/build-shell-capture.ts +155 -146
  163. package/src/route-definition/dsl-helpers.ts +2 -0
  164. package/src/route-definition/helpers-types.ts +11 -11
  165. package/src/route-name.ts +12 -0
  166. package/src/router/content-negotiation.ts +4 -1
  167. package/src/router/error-handling.ts +5 -2
  168. package/src/router/handler-context.ts +10 -7
  169. package/src/router/intercept-resolution.ts +11 -1
  170. package/src/router/loader-resolution.ts +28 -3
  171. package/src/router/logging.ts +5 -3
  172. package/src/router/match-api.ts +62 -34
  173. package/src/router/match-context.ts +5 -0
  174. package/src/router/match-handlers.ts +9 -29
  175. package/src/router/match-middleware/background-revalidation.ts +3 -5
  176. package/src/router/match-middleware/cache-lookup.ts +35 -19
  177. package/src/router/match-middleware/cache-store.ts +15 -9
  178. package/src/router/match-result.ts +1 -0
  179. package/src/router/middleware.ts +2 -5
  180. package/src/router/navigation-snapshot.ts +6 -5
  181. package/src/router/prerender-match.ts +4 -0
  182. package/src/router/request-classification.ts +7 -4
  183. package/src/router/router-options.ts +4 -1
  184. package/src/router/segment-resolution/fresh.ts +0 -4
  185. package/src/router/segment-resolution/loader-cache.ts +150 -25
  186. package/src/router/segment-resolution/loader-mask.ts +3 -4
  187. package/src/router/segment-resolution/loader-snapshot.ts +15 -78
  188. package/src/router/segment-resolution/mask-nested.ts +47 -22
  189. package/src/router/segment-resolution/revalidation.ts +0 -3
  190. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  191. package/src/router/telemetry.ts +6 -3
  192. package/src/router/transition-when.ts +34 -52
  193. package/src/router.ts +2 -1
  194. package/src/rsc/attach-transition-when.ts +47 -0
  195. package/src/rsc/full-payload.ts +30 -10
  196. package/src/rsc/handler.ts +12 -10
  197. package/src/rsc/loader-fetch.ts +2 -1
  198. package/src/rsc/manifest-init.ts +4 -0
  199. package/src/rsc/origin-guard.ts +8 -5
  200. package/src/rsc/progressive-enhancement.ts +7 -30
  201. package/src/rsc/response-cache-serve.ts +68 -80
  202. package/src/rsc/response-route-handler.ts +2 -2
  203. package/src/rsc/rsc-rendering.ts +206 -252
  204. package/src/rsc/server-action.ts +8 -20
  205. package/src/rsc/shell-build-manifest.ts +22 -20
  206. package/src/rsc/shell-capture-constants.ts +136 -20
  207. package/src/rsc/shell-capture.ts +352 -589
  208. package/src/rsc/shell-serve.ts +73 -109
  209. package/src/rsc/ssr-setup.ts +3 -2
  210. package/src/rsc/types.ts +2 -0
  211. package/src/segment-system.tsx +40 -7
  212. package/src/server/context.ts +189 -124
  213. package/src/server/cookie-store.ts +153 -127
  214. package/src/server/handle-store.ts +245 -27
  215. package/src/server/request-context.ts +123 -84
  216. package/src/server/request-headers.ts +134 -0
  217. package/src/server.ts +4 -0
  218. package/src/testing/dispatch.ts +4 -7
  219. package/src/testing/e2e/index.ts +1 -0
  220. package/src/testing/index.ts +5 -0
  221. package/src/testing/internal/action-id.ts +25 -0
  222. package/src/testing/internal/context.ts +1 -1
  223. package/src/testing/location-state-key.ts +45 -0
  224. package/src/testing/render-handler.ts +1 -1
  225. package/src/testing/render-route.tsx +109 -16
  226. package/src/testing/run-client-revalidate.ts +3 -19
  227. package/src/testing/run-loader.ts +5 -1
  228. package/src/testing/run-transition-when.ts +109 -172
  229. package/src/testing/serve-shell-request.ts +10 -7
  230. package/src/testing/shell-status.ts +60 -56
  231. package/src/transition-when-ref.ts +220 -0
  232. package/src/types/cache-types.ts +15 -3
  233. package/src/types/index.ts +3 -0
  234. package/src/types/segments.ts +94 -91
  235. package/src/urls/path-helper-types.ts +3 -4
  236. package/src/urls/pattern-types.ts +3 -4
  237. package/src/vite/discovery/discover-routers.ts +3 -0
  238. package/src/vite/discovery/shell-prerender-phase.ts +0 -2
  239. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  240. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  241. package/src/vite/rango.ts +2 -0
  242. package/src/vite/router-discovery.ts +75 -5
  243. package/dist/types/rsc/transition-gate.d.ts +0 -23
  244. package/src/rsc/transition-gate.ts +0 -86
@@ -6,14 +6,17 @@
6
6
  * from the route's `ppr` path option after the served response is built and calls
7
7
  * scheduleShellCapture. The capture then runs as a background task that re-derives
8
8
  * the page via `ctx.router.match()` under its OWN derived request context — fresh
9
- * handle store, `_shellCaptureRun: true` so loaders mask (loader-mask.ts) and every
10
- * loading() subtree postpones. The render is MIXED-CHAIN: cache()'d segments replay
11
- * from ring 3, uncached segments execute their handlers fresh. It drives the static
12
- * prerender to a quiescent shell, aborts to freeze the prelude + postponed state,
13
- * and stores the pair via putShell. Because it uses match() rather than the HTTP
14
- * pipeline, the middleware chain (auth, logging) never re-runs — it already ran for
15
- * the triggering request, and the derived context inherits its post-middleware
16
- * state (variables, cache store). Guarding is serve-time.
9
+ * handle store, `_shellCaptureRun: true` so live-lane loaders mask (loader-mask.ts)
10
+ * and postpone at their loading() or inline <Suspense> boundary. The match is
11
+ * MIXED-CHAIN: cache()'d segments replay from ring 3, uncached segments execute
12
+ * their handlers. The record-first step (settleCaptureRecord) waits for the
13
+ * handler layer, writes the doc record every HIT replays, and renders the
14
+ * capture's Flight payload from it; the static prerender runs to a quiescent
15
+ * shell, aborts to freeze the prelude + postponed state, and putShell stores the
16
+ * pair with the snapshot. Because it uses match() rather than the HTTP pipeline,
17
+ * the middleware chain (auth, logging) never re-runs — it already ran for the
18
+ * triggering request, and the derived context inherits its post-middleware state
19
+ * (variables, cache store). Guarding is serve-time.
17
20
  */
18
21
 
19
22
  import React from "react";
@@ -21,7 +24,11 @@ import { isLoaderDataResult, type MatchResult } from "../types.js";
21
24
  import { bytesToBase64 } from "../cache/cf/cf-base64.js";
22
25
  import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
23
26
  import { reportCacheError } from "../cache/cache-error.js";
24
- import { runBackground } from "../cache/background-task.js";
27
+ import {
28
+ raceDeadline,
29
+ runBackground,
30
+ settleGrowing,
31
+ } from "../cache/background-task.js";
25
32
  import {
26
33
  CaptureQueueFullError,
27
34
  CaptureQueueWaitTimeoutError,
@@ -32,6 +39,8 @@ import {
32
39
  PPR_LANE_HINT,
33
40
  SHELL_CAPTURE_MAX_WAIT_MS,
34
41
  SHELL_CAPTURE_TASK_HARD_CAP_MS,
42
+ resetShellWarningsForTests,
43
+ warnOnce,
35
44
  } from "./shell-capture-constants.js";
36
45
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
37
46
  import { observePhase, PHASES } from "../router/instrument.js";
@@ -56,10 +65,12 @@ import {
56
65
  type MaskReport,
57
66
  } from "../router/segment-resolution/mask-nested.js";
58
67
  import {
68
+ findEnclosingLoaderBody,
59
69
  getCurrentLoaderBodyId,
60
70
  isInsideLoaderScope,
61
71
  } from "../server/context.js";
62
72
  import { isThenable } from "../handles/is-thenable.js";
73
+ import { requestHeaders } from "../server/request-headers.js";
63
74
  import type {
64
75
  CachedEntryData,
65
76
  ShellCacheEntry,
@@ -78,7 +89,6 @@ import {
78
89
  getRecordingStore,
79
90
  hasDocRecord,
80
91
  pruneShellSnapshot,
81
- type ShellSnapshotReaders,
82
92
  } from "../cache/shell-snapshot.js";
83
93
  import type { HandlerContext } from "./handler-context.js";
84
94
  import type { SSRModule } from "./types.js";
@@ -103,21 +113,13 @@ import { stripInternalParams } from "../router/handler-context.js";
103
113
  *
104
114
  * K=2 gives a race window of ~two event-loop turns: shell work still producing
105
115
  * bytes keeps resetting the counter; anything not producing bytes within the
106
- * window (the masked loaders, and any genuinely pending I/O) becomes a hole. The
107
- * only residual is raw per-request I/O rendered directly in shell (not via a
108
- * loader) that resolves inside the window — a documented shell anti-pattern; put
109
- * per-request data in loaders. See docs/design/ppr-shell-resume.md.
116
+ * window (the masked loaders) becomes a hole. Handler output never races it: the
117
+ * capture's Flight input is the doc record's fragments, rendered after
118
+ * settleCaptureRecord waited for the handler layer. See
119
+ * docs/design/ppr-shell-resume.md.
110
120
  */
111
121
  const FLIGHT_QUIET_HOPS = 2;
112
122
 
113
- /**
114
- * Default capture budget. Canonical value, raise rationale, and ceiling math
115
- * live on the leaf module (shell-capture-constants.ts, importable from the ssr
116
- * graph too). Re-exported here so shell-build-manifest.ts's envelope math
117
- * keeps its existing import site and cannot drift from the capture.
118
- */
119
- export { SHELL_CAPTURE_MAX_WAIT_MS };
120
-
121
123
  /**
122
124
  * Upper bound on waiting for the capture's DEFERRED cache writes to settle before
123
125
  * draining the snapshot. Cache writes run under waitUntil (fire-and-forget on
@@ -154,33 +156,17 @@ const SHELL_CAPTURE_WRITE_BARRIER_MS = 1500;
154
156
 
155
157
  /**
156
158
  * Settle the tracked background tasks on `reqCtx._pendingBackgroundTasks`,
157
- * ITERATIVELY: a settled task can have scheduled a nested one (cache-store's
158
- * cacheRoute outer task schedules the actual store.set in a second waitUntil), so
159
- * each awaited batch may append more. Loop until no new tasks appear or the
160
- * deadline passes. The capture's own task never enters the list
161
- * (UNTRACKED_BACKGROUND_TASK), so the loop terminates.
159
+ * including nested ones a settled task scheduled (cache-store's cacheRoute
160
+ * outer task schedules the actual store.set in a second waitUntil), until the
161
+ * list stops growing or the timeout passes. The capture's own task never
162
+ * enters the list (UNTRACKED_BACKGROUND_TASK), so the wait terminates.
162
163
  */
163
164
  async function settleTrackedBackgroundTasks(
164
165
  reqCtx: RequestContext<any>,
165
166
  timeoutMs: number,
166
167
  ): Promise<void> {
167
168
  const tasks = reqCtx._pendingBackgroundTasks;
168
- if (!tasks) return;
169
- const deadline = Date.now() + timeoutMs;
170
- let seen = 0;
171
- while (tasks.length > seen) {
172
- const remaining = deadline - Date.now();
173
- if (remaining <= 0) return;
174
- const batch = tasks.slice(seen);
175
- seen = tasks.length;
176
- let timer: ReturnType<typeof setTimeout> | undefined;
177
- const guard = new Promise<void>((resolve) => {
178
- timer = setTimeout(resolve, remaining);
179
- (timer as { unref?: () => void }).unref?.();
180
- });
181
- await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
182
- if (timer) clearTimeout(timer);
183
- }
169
+ if (tasks) await settleGrowing(tasks, Date.now() + timeoutMs);
184
170
  }
185
171
 
186
172
  /**
@@ -210,31 +196,18 @@ function delay(ms: number): Promise<void> {
210
196
  * Bound one capture task at {@link SHELL_CAPTURE_TASK_HARD_CAP_MS}. Rejects on
211
197
  * expiry so the caller's existing catch applies backoff + reporting and its
212
198
  * settle path releases the stampede guard and queue slot; resolves/rejects
213
- * transparently otherwise. The timer is unref'd so a pending cap never holds a
214
- * Node dev process open.
199
+ * transparently otherwise.
215
200
  */
216
- function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
217
- return new Promise<T>((resolve, reject) => {
218
- const timer = setTimeout(() => {
219
- reject(
220
- new Error(
221
- `capture task for ${key} exceeded the ${SHELL_CAPTURE_TASK_HARD_CAP_MS}ms hard cap; ` +
222
- `a handler is likely wedged on a never-settling await`,
223
- ),
224
- );
225
- }, SHELL_CAPTURE_TASK_HARD_CAP_MS);
226
- (timer as { unref?: () => void }).unref?.();
227
- task.then(
228
- (value) => {
229
- clearTimeout(timer);
230
- resolve(value);
231
- },
232
- (error: unknown) => {
233
- clearTimeout(timer);
234
- reject(error);
235
- },
236
- );
237
- });
201
+ async function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
202
+ const raced = await raceDeadline(
203
+ task,
204
+ Date.now() + SHELL_CAPTURE_TASK_HARD_CAP_MS,
205
+ );
206
+ if (raced.done) return raced.value;
207
+ throw new Error(
208
+ `capture task for ${key} exceeded the ${SHELL_CAPTURE_TASK_HARD_CAP_MS}ms hard cap; ` +
209
+ `a handler is likely wedged on a never-settling await`,
210
+ );
238
211
  }
239
212
 
240
213
  /**
@@ -254,6 +227,13 @@ function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
254
227
  * keeps a late-settling stale task from releasing its replacement's entry.
255
228
  */
256
229
  interface CaptureGuardToken {
230
+ /**
231
+ * The schedule time while the capture waits in the queue (which drops it at
232
+ * CAPTURE_QUEUE_WAIT_BUDGET_MS, under the cap), then the time its hard cap
233
+ * started. Stamped only at schedule time, a capture that had waited in the
234
+ * queue read as older than its cap while still inside it, and the next
235
+ * request scheduled a duplicate capture of the key.
236
+ */
257
237
  startedAt: number;
258
238
  }
259
239
 
@@ -327,7 +307,7 @@ function refusedCaptureCeilingMs(): number {
327
307
  */
328
308
  const refusedCaptures = new Map<string, { failures: number; until: number }>();
329
309
 
330
- /** True iff `key` is still inside its (exponential) backoff window. */
310
+ /** A bake-lane container that settled with redirect() or notFound(). */
331
311
  function isSettledLoaderSignal(value: unknown): boolean {
332
312
  return (
333
313
  isLoaderDataResult(value) &&
@@ -336,6 +316,7 @@ function isSettledLoaderSignal(value: unknown): boolean {
336
316
  );
337
317
  }
338
318
 
319
+ /** True iff `key` is still inside its (exponential) backoff window. */
339
320
  function isCaptureBackedOff(key: string): boolean {
340
321
  const entry = refusedCaptures.get(key);
341
322
  if (entry === undefined) return false;
@@ -366,16 +347,11 @@ function clearCaptureBackoff(key: string): void {
366
347
  refusedCaptures.delete(key);
367
348
  }
368
349
 
369
- /**
370
- * Keys already warned about a refused (null) capture, so the eternal-MISS shape
371
- * logs once per key per isolate instead of on every request.
372
- */
373
- const warnedNullCaptures = new Set<string>();
374
-
375
350
  /**
376
351
  * Warn once per key that a capture produced no usable shell: after the
377
352
  * 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
353
+ * out of its deadline (no retry: CaptureAttemptStats.noShellCause), with the
354
+ * terminal attempt's cause and pending component stacks. Naming both causes with the
379
355
  * distinguishing signal — does the route ever flip to HIT — is the whole point:
380
356
  * the pre-retry version blamed "a loader route without loading()" unconditionally
381
357
  * and misled users whose route DID have loading() and was merely cold. Because the
@@ -386,15 +362,18 @@ const warnedNullCaptures = new Set<string>();
386
362
  * The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
387
363
  * tarball, so the path resolves for consumers (a05c8251 convention).
388
364
  */
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);
394
- if (warnedNullCaptures.has(key)) return;
395
- warnedNullCaptures.add(key);
396
- console.warn(
397
- `[rango] Shell capture for "${key}" produced no usable shell` +
365
+ function warnNullCaptureOnce(
366
+ key: string,
367
+ retried: boolean,
368
+ attempt: CaptureAttemptStats,
369
+ ): void {
370
+ const cause = attempt.noShellCause;
371
+ const pendingStacks = attempt.pendingStacks;
372
+ warnOnce(
373
+ "capture-no-shell",
374
+ key,
375
+ () =>
376
+ `[rango] Shell capture for "${key}" produced no usable shell` +
398
377
  (retried ? " after an in-place retry" : "") +
399
378
  "; nothing was stored, so this request stays on MISS. " +
400
379
  (cause
@@ -421,22 +400,6 @@ function warnNullCaptureOnce(key: string, retried: boolean): void {
421
400
  );
422
401
  }
423
402
 
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
403
  /** Stacks kept per attempt: the root pin is almost always among the first. */
441
404
  const MAX_PENDING_STACKS = 3;
442
405
 
@@ -451,9 +414,6 @@ function formatPendingStack(stack: string): string {
451
414
  .join("\n");
452
415
  }
453
416
 
454
- /** Keys already warned about a deterministic capture refusal (once per key). */
455
- const warnedRefusedCaptures = new Set<string>();
456
-
457
417
  /**
458
418
  * Warn once per key that the capture was REFUSED for a deterministic reason
459
419
  * (identity-guard trip, a rejected or signal-settled bake-lane loader, a store
@@ -467,10 +427,11 @@ function warnCaptureRefusedOnce(
467
427
  reason: string,
468
428
  pointer: string = "See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
469
429
  ): void {
470
- if (warnedRefusedCaptures.has(key)) return;
471
- warnedRefusedCaptures.add(key);
472
- console.warn(
473
- `[rango] Shell capture for "${key}" was refused: ${reason}\n` +
430
+ warnOnce(
431
+ "capture-refused",
432
+ key,
433
+ () =>
434
+ `[rango] Shell capture for "${key}" was refused: ${reason}\n` +
474
435
  "The route stays on MISS (axis 1) — the page keeps working, only the shell " +
475
436
  `cache is off. ${pointer}`,
476
437
  );
@@ -483,7 +444,7 @@ function warnCaptureRefusedOnce(
483
444
  * an async server component, a bake-lane loader. The read throws where it
484
445
  * happens, but the code can catch that throw, so the flag the guard sets on
485
446
  * the capture context is what decides (server/context.ts
486
- * assertNotInsideShellCapture). Returns true when it refused (and warned).
447
+ * guardIdentityRead). Returns true when it refused (and warned).
487
448
  */
488
449
  function refuseOnCaptureGuard(
489
450
  key: string,
@@ -506,7 +467,7 @@ function refuseOnCaptureGuard(
506
467
  // Name the recorded source instead of hardcoding a lane: a trip can come
507
468
  // from a bake-lane SEGMENT loader or from handler/render code, including a
508
469
  // loader a handler awaits (issue #672). The guard recorded the read and its
509
- // fix (server/context.ts tripShellCaptureGuard): cookies()/headers(), a
470
+ // fix (server/context.ts guardIdentityRead): cookies()/headers(), a
510
471
  // { cache: false } variable, and the theme reads (#971).
511
472
  const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
512
473
  const origin = loaderId
@@ -559,9 +520,6 @@ export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
559
520
  /** Cached encoder for the snapshot byte measurement (one per module, not per capture). */
560
521
  const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
561
522
 
562
- /** Keys already warned about an over-cap snapshot (once per key per isolate). */
563
- const warnedOverCapSnapshots = new Set<string>();
564
-
565
523
  /**
566
524
  * Warn once per key that the capture's loader pins exceeded the route's
567
525
  * `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
@@ -576,10 +534,11 @@ function warnSnapshotOverCapOnce(
576
534
  snapshotBytes: number,
577
535
  capBytes: number,
578
536
  ): void {
579
- if (warnedOverCapSnapshots.has(key)) return;
580
- warnedOverCapSnapshots.add(key);
581
- console.warn(
582
- `[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
537
+ warnOnce(
538
+ "snapshot-over-cap",
539
+ key,
540
+ () =>
541
+ `[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
583
542
  `loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
584
543
  "the shell was stored with its doc record only. HITs still replay the " +
585
544
  "handler layer, but ssr: false loaders now read the live store: if " +
@@ -592,30 +551,28 @@ function warnSnapshotOverCapOnce(
592
551
  }
593
552
 
594
553
  /**
595
- * Dev warning threshold (ms) for the slowest bake-source settlement wait. Bake
596
- * time is by-design shell latency (top-level pushed handle promises and
597
- * bake-lane loader containers settle before the freeze), but it recurs on
598
- * EVERY capture of the route and occupies the per-isolate serialized capture
599
- * queue — silently: the served response's own timing never shows it (a route
600
- * can read 13ms TTFB while each of its captures holds the queue for seconds).
601
- * Past this threshold the source gets named once per key with the remedy
602
- * ladder.
554
+ * Dev warning threshold (ms) for the record-first settle
555
+ * (CaptureAttemptStats.recordSettleMs). Bake time is by-design shell latency
556
+ * (handler output, top-level pushed handle promises and bake-lane loader
557
+ * containers settle before the freeze), but it recurs on EVERY capture of the
558
+ * route and occupies the per-isolate serialized capture queue — silently: the
559
+ * served response's own timing never shows it (a route can read 13ms TTFB
560
+ * while each of its captures holds the queue for seconds). Past this
561
+ * threshold the cost gets named once per key with the remedy ladder.
603
562
  */
604
563
  const SHELL_CAPTURE_BAKE_WARN_MS = 2_000;
605
564
 
606
- /** Keys already warned about an expensive bake source (once per key). */
607
- const warnedBakeCosts = new Set<string>();
608
-
609
565
  /**
610
- * Dev-only, once per key: name the slowest bake source and the three remedies
611
- * in preference order. Handler output and bake-lane values are shell material
566
+ * Dev-only, once per key: name the bake cost and the three remedies in
567
+ * preference order. Handler output and bake-lane values are shell material
612
568
  * by design, so this is a cost report with an exit, not a deprecation.
613
569
  */
614
570
  function warnBakeCostOnce(key: string, source: string, ms: number): void {
615
- if (warnedBakeCosts.has(key)) return;
616
- warnedBakeCosts.add(key);
617
- console.warn(
618
- `[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
571
+ warnOnce(
572
+ "bake-cost",
573
+ key,
574
+ () =>
575
+ `[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
619
576
  "settle before the shell could freeze. This cost recurs on every capture " +
620
577
  "of the route and occupies the per-isolate capture queue; the served " +
621
578
  "response never shows it. To keep it baked but cheap, wrap the work in " +
@@ -714,15 +671,6 @@ export interface ShellCaptureDebugEvent {
714
671
  * same-or-higher priority. 0 = this capture starts immediately.
715
672
  */
716
673
  queueAhead?: number;
717
- /**
718
- * Ms the capture gate was HELD waiting for the slowest bake source — a
719
- * top-level pushed handle promise or a bake-lane loader container — to
720
- * settle, measured from capture start. Bake time is by-design shell latency
721
- * (the hole doctrine: top-level promises bake), but it recurs on EVERY
722
- * capture of the route and occupies the serialized queue; this field makes
723
- * it attributable. Absent when nothing held the gate.
724
- */
725
- bakeWaitMs?: number;
726
674
  /**
727
675
  * How long the capture waited for its doc record: the handler layer's
728
676
  * promises, async server components and handle pushes settling, then the
@@ -786,9 +734,6 @@ export function describeShellCaptureEvent(
786
734
  if (event.queueAhead !== undefined) {
787
735
  parts.push(`queue-ahead=${event.queueAhead}`);
788
736
  }
789
- if (event.bakeWaitMs !== undefined) {
790
- parts.push(`bake=${event.bakeWaitMs}ms`);
791
- }
792
737
  if (event.recordSettleMs !== undefined) {
793
738
  parts.push(`record=${event.recordSettleMs}ms`);
794
739
  }
@@ -936,22 +881,13 @@ export interface FlightCaptureGate {
936
881
  * closing (no holes) fires quiesce immediately for the DATA variant — the
937
882
  * TransformStream then closes the readable, so fizz completes with postponed null.
938
883
  *
939
- * `holdUntil` keeps the gate from FREEZING before shell material with real latency
940
- * has emitted. The hole doctrine bakes TOP-LEVEL pushed handle promises into the
941
- * shell (resolvedHandleStream awaits them before the handles row emits), but a
942
- * pushed promise that takes longer than the quiet window would otherwise be frozen
943
- * out — the handles row would never reach fizz and the prelude would come back
944
- * trivial. While `holdUntil` is pending, byte-quiet detection keeps running but the
945
- * gate neither fires nor freezes; once it resolves, the quiet counter restarts so a
946
- * burst of rows unblocked by it (the resolved handles row) is still captured. It
947
- * never delays a HOLE from postponing: holes are pending promises that emit no
948
- * bytes, so holding the gate open longer only ever admits shell rows. Bounded by
949
- * captureShellHTML's maxWaitMs like every other quiesce input.
884
+ * Nothing the shell bakes can still be pending here: settleCaptureRecord already
885
+ * waited for the top-level handle promises and the bake-lane loader containers
886
+ * before the capture's Flight render began, so the handles row and the baked
887
+ * loader rows emit at once and only the holes stay silent.
950
888
  */
951
889
  export function gateFlightForCapture(
952
890
  source: ReadableStream<Uint8Array>,
953
- quietHops: number = FLIGHT_QUIET_HOPS,
954
- holdUntil?: Promise<unknown>,
955
891
  ): FlightCaptureGate {
956
892
  let resolveQuiet!: () => void;
957
893
  const quiesce = new Promise<void>((resolve) => {
@@ -963,32 +899,9 @@ export function gateFlightForCapture(
963
899
  let settled = false;
964
900
  let disposed = false;
965
901
  let frozen = false;
966
- let held = holdUntil !== undefined;
967
- let heldFirePending = false;
968
-
969
- if (holdUntil !== undefined) {
970
- const release = (): void => {
971
- held = false;
972
- if (heldFirePending && !settled && !disposed) {
973
- // Quiet elapsed while held: restart the quiet count instead of firing
974
- // immediately, so rows unblocked by the hold (the baked handles row)
975
- // still flow before the freeze.
976
- heldFirePending = false;
977
- armed = false;
978
- arm();
979
- }
980
- };
981
- // Resolve OR reject releases the hold (a rejected handle value is dropped by
982
- // resolveDeferredHandleValues; the capture must not hang on it).
983
- holdUntil.then(release, release);
984
- }
985
902
 
986
903
  const fire = (): void => {
987
904
  if (settled) return;
988
- if (held) {
989
- heldFirePending = true;
990
- return;
991
- }
992
905
  settled = true;
993
906
  frozen = true;
994
907
  resolveQuiet();
@@ -1012,7 +925,7 @@ export function gateFlightForCapture(
1012
925
  if (settled || disposed) return;
1013
926
  if (bytesSeen === lastSeen) {
1014
927
  quiet += 1;
1015
- if (quiet >= quietHops) {
928
+ if (quiet >= FLIGHT_QUIET_HOPS) {
1016
929
  fire();
1017
930
  return;
1018
931
  }
@@ -1098,16 +1011,13 @@ export interface ShellCaptureDescriptor {
1098
1011
  tags?: string[];
1099
1012
  /**
1100
1013
  * Per-route capture settle budget in ms (`ppr.captureTimeout`, resolved by
1101
- * resolvePprConfig). Feeds captureShellHTML's maxWaitMs — the ONE deadline
1102
- * bounding the whole capture, so it covers BOTH the fizz prerender AND the
1103
- * deferred-material settle window (the handlesBaked/loader-container
1104
- * holdUntil that keeps the gate from freezing while top-level pushes are
1105
- * pending). Undefined = SHELL_CAPTURE_MAX_WAIT_MS (15_000).
1014
+ * resolvePprConfig): the ONE deadline bounding the whole capture — the match,
1015
+ * the record-first settle (settleCaptureRecord), then the fizz prerender with
1016
+ * what is left (captureShellHTML's maxWaitMs). Undefined =
1017
+ * SHELL_CAPTURE_MAX_WAIT_MS (15_000).
1106
1018
  */
1107
1019
  captureTimeout?: number;
1108
1020
  store?: SegmentCacheStore<any>;
1109
- /** Gates the concise per-attempt capture breadcrumbs (INTERNAL_RANGO_DEBUG). */
1110
- debug?: boolean;
1111
1021
  /**
1112
1022
  * Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
1113
1023
  * the snapshot is skipped and the shell stored without it (reported once per
@@ -1219,16 +1129,23 @@ export function scheduleShellCapture(
1219
1129
  const guardToken: CaptureGuardToken = { startedAt: Date.now() };
1220
1130
  inFlightCaptures.set(key, guardToken);
1221
1131
  const captureTask = async (span: TraceSpan) => {
1132
+ // The guard's age counts from the task's start, not its scheduling: a
1133
+ // capture queued behind others (up to CAPTURE_QUEUE_WAIT_BUDGET_MS) is
1134
+ // not abandoned while its SSR setup loads.
1135
+ guardToken.startedAt = Date.now();
1222
1136
  try {
1223
- const setupUrl = descriptor.navigationOnly
1137
+ // A navigation-only capture renders under document request identity:
1138
+ // its transport parameters and headers are stripped once, here, for the
1139
+ // SSR setup and every attempt.
1140
+ const captureUrl = descriptor.navigationOnly
1224
1141
  ? stripInternalParams(url)
1225
1142
  : url;
1226
- const setupRequest = descriptor.navigationOnly
1227
- ? createNavigationCaptureRequest(request, setupUrl)
1143
+ const captureRequest = descriptor.navigationOnly
1144
+ ? createNavigationCaptureRequest(request, captureUrl)
1228
1145
  : request;
1229
1146
  const resolvedSsrModule =
1230
1147
  typeof ssrModule === "function"
1231
- ? await ssrModule(setupRequest, setupUrl)
1148
+ ? await ssrModule(captureRequest, captureUrl)
1232
1149
  : ssrModule;
1233
1150
  if (
1234
1151
  !resolvedSsrModule ||
@@ -1237,19 +1154,21 @@ export function scheduleShellCapture(
1237
1154
  ) {
1238
1155
  return;
1239
1156
  }
1240
- // Hard-capped: SHELL_CAPTURE_MAX_WAIT_MS arms only inside
1241
- // captureShellHTML, AFTER the capture's router.match() — a handler
1242
- // wedged on a never-settling upstream await has no deadline in force
1243
- // and would strand the stampede guard and the queue slot (production
1244
- // pilot). The cap rejects, riding the existing catch: backoff +
1245
- // reportCacheError + token-guarded release. The abandoned attempt keeps
1246
- // running until its context dies; nothing awaits it.
1157
+ // Hard-capped: ppr.captureTimeout bounds the attempt, but a match that
1158
+ // loses its deadline keeps running (no abort signal), and a handler
1159
+ // wedged on a never-settling upstream await would strand the stampede
1160
+ // guard and the queue slot (production pilot). The cap rejects, riding
1161
+ // the existing catch: backoff + reportCacheError + token-guarded
1162
+ // release. The abandoned attempt keeps running until its context dies;
1163
+ // nothing awaits it. From here the guard's age counts from the cap's
1164
+ // start, so a slow SSR setup does not shorten the capped run.
1165
+ guardToken.startedAt = Date.now();
1247
1166
  const outcome = await raceTaskHardCap(
1248
1167
  runShellCapture(
1249
1168
  ctx,
1250
- request,
1169
+ captureRequest,
1251
1170
  env,
1252
- url,
1171
+ captureUrl,
1253
1172
  reqCtx,
1254
1173
  resolvedSsrModule,
1255
1174
  descriptor,
@@ -1260,11 +1179,13 @@ export function scheduleShellCapture(
1260
1179
  // Update the negative cache off the terminal outcome. A stored shell clears
1261
1180
  // any prior backoff; a `no-shell` (after the in-place retry) backs the key
1262
1181
  // off so the next requests don't re-probe it. A `redirect` has no shell but
1263
- // is not a doomed render, and an `expired` retry lost a second record to
1264
- // the same race — leave the backoff untouched.
1182
+ // is not a doomed render — leave the backoff untouched.
1265
1183
  if (outcome === "stored") {
1266
1184
  clearCaptureBackoff(key);
1267
- noteCaptureStored(key);
1185
+ // Only a document entry is the shell a document MISS read the store
1186
+ // for: document serving reads a navigation-only entry as a MISS, even
1187
+ // one a corrupt-snapshot heal stored under the document key.
1188
+ if (!descriptor.navigationOnly) noteCaptureStored(key);
1268
1189
  } else if (outcome === "no-shell") {
1269
1190
  markCaptureBackoff(key);
1270
1191
  publishCaptureDebugEvent(descriptor, {
@@ -1408,9 +1329,6 @@ export function capShellWindow(
1408
1329
  return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
1409
1330
  }
1410
1331
 
1411
- /** Routes already warned that their cache() entry expires before a capture. */
1412
- const warnedExpiredRecordRoutes = new Set<string>();
1413
-
1414
1332
  /**
1415
1333
  * Warn once per route (dev and production) that no shell was stored because
1416
1334
  * the route cache() entry ran out before the capture could store one
@@ -1424,10 +1342,11 @@ function warnRecordRanOutOnce(
1424
1342
  record: RouteRecordWindow,
1425
1343
  detail: string,
1426
1344
  ): 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 ` +
1345
+ warnOnce(
1346
+ "record-ran-out",
1347
+ route,
1348
+ () =>
1349
+ `[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
1431
1350
  `${record.swr}) ran out before the shell capture could store a shell ` +
1432
1351
  `(${detail}), so no shell is stored and the route renders without ` +
1433
1352
  "one. A shell never outlives the route cache() entry it replays: give " +
@@ -1437,22 +1356,14 @@ function warnRecordRanOutOnce(
1437
1356
  }
1438
1357
 
1439
1358
  /**
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.
1359
+ * A terminal `expired`: warn once per route with the record the attempt ran
1360
+ * out on, and return `no-shell` so the caller backs the key off.
1452
1361
  */
1453
- function expiredTerminal(key: string, reason: string): "no-shell" {
1454
- const expired = expiredRecords.get(key);
1455
- expiredRecords.delete(key);
1362
+ function expiredTerminal(
1363
+ attempt: CaptureAttemptStats,
1364
+ reason: string,
1365
+ ): "no-shell" {
1366
+ const expired = attempt.expired;
1456
1367
  if (expired) {
1457
1368
  warnRecordRanOutOnce(
1458
1369
  expired.route,
@@ -1472,11 +1383,11 @@ function expiredTerminal(key: string, reason: string): "no-shell" {
1472
1383
  * - `no-shell`: captureShellHTML returned null because the prelude was unusable
1473
1384
  * or its private capture abort landed before the shell completed, or the
1474
1385
  * capture ran out of ppr.captureTimeout. Retried once in place, when no
1475
- * deadline ran out (noShellCauses).
1386
+ * deadline ran out (CaptureAttemptStats.noShellCause).
1476
1387
  * - `expired`: the route cache() record the capture read (not wrote) ran out
1477
1388
  * before the store (capShellWindow null): it was near its end (its age,
1478
1389
  * 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
1390
+ * a newer record; an `expired` retry, or no hard-cap budget left for the
1480
1391
  * retry, is terminal `no-shell` (backoff, warned once per route).
1481
1392
  */
1482
1393
  type CaptureAttemptOutcome =
@@ -1486,8 +1397,14 @@ type CaptureAttemptOutcome =
1486
1397
  | "refused"
1487
1398
  | "expired";
1488
1399
 
1400
+ /**
1401
+ * What a capture task ends with (runShellCapture): a refusal and a terminal
1402
+ * `expired` are `no-shell`, which the scheduler backs off.
1403
+ */
1404
+ type CaptureTaskOutcome = "stored" | "redirect" | "no-shell";
1405
+
1489
1406
  function createNavigationCaptureRequest(request: Request, url: URL): Request {
1490
- const headers = new Headers(request.headers);
1407
+ const headers = new Headers(requestHeaders(request));
1491
1408
  headers.set("accept", "text/html");
1492
1409
  for (const name of [
1493
1410
  "rsc-action",
@@ -1503,11 +1420,12 @@ function createNavigationCaptureRequest(request: Request, url: URL): Request {
1503
1420
  }
1504
1421
 
1505
1422
  /**
1506
- * Per-attempt observability fields, filled along the capture path (barrier in
1507
- * attemptCapture, the rest in captureAndStoreShell) and folded into the
1508
- * attempt's {@link ShellCaptureDebugEvent} by runShellCapture. A plain mutable
1509
- * bag, not a return value: captureAndStoreShell's outcome type stays a string
1510
- * union its existing callers (producer B, tests) consume unchanged.
1423
+ * One attempt's record, filled along the capture path (barrier and deadline
1424
+ * causes in attemptCapture, the rest in captureAndStoreShell). runShellCapture
1425
+ * folds the observability fields into the attempt's
1426
+ * {@link ShellCaptureDebugEvent} and reads the rest to decide the retry and
1427
+ * the warning. A plain mutable bag, not a return value: captureAndStoreShell's
1428
+ * outcome type stays a string union producer B consumes unchanged.
1511
1429
  */
1512
1430
  type CaptureAttemptStats = Pick<
1513
1431
  ShellCaptureDebugEvent,
@@ -1518,10 +1436,24 @@ type CaptureAttemptStats = Pick<
1518
1436
  | "snapshotSkipped"
1519
1437
  | "untaggedBake"
1520
1438
  | "storeWrite"
1521
- | "bakeWaitMs"
1522
1439
  | "recordSettleMs"
1523
1440
  | "entryBytes"
1524
- >;
1441
+ > & {
1442
+ /**
1443
+ * Why a no-shell attempt ran out of ppr.captureTimeout (the match, the
1444
+ * handler output, or the prerender after them). A set cause skips the
1445
+ * in-place retry: the attempt's match may still be running.
1446
+ */
1447
+ noShellCause?: string;
1448
+ /**
1449
+ * Dev only: component stacks of the tasks still pending at a no-shell
1450
+ * attempt's abort, reported through prerender's onError (captureShellHTML's
1451
+ * onAbortedTask).
1452
+ */
1453
+ pendingStacks?: string[];
1454
+ /** The route cache() record an `expired` attempt ran out on. */
1455
+ expired?: { route: string; record: RouteRecordWindow; captureMs: number };
1456
+ };
1525
1457
 
1526
1458
  /**
1527
1459
  * Run the shell capture with a single in-place retry, then store the result.
@@ -1535,12 +1467,14 @@ type CaptureAttemptStats = Pick<
1535
1467
  * attempt a short beat later usually completes the shell in the SAME background
1536
1468
  * task. That kills the old multi-request warmup where the caller had to re-issue
1537
1469
  * 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
1540
- * reportCacheError. See docs/design/ppr-shell-resume.md.
1470
+ * that did not run out of ppr.captureTimeout (CaptureAttemptStats.noShellCause),
1471
+ * and on `expired`; a genuine render error is NOT retried — it propagates to
1472
+ * scheduleShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
1541
1473
  *
1542
- * `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
1543
- * drive the retry without a real 400ms wall-clock wait.
1474
+ * `request` and `url` are the capture's own identity (scheduleShellCapture
1475
+ * strips a navigation-only capture's transport parameters). `retryDelayMs` is
1476
+ * a parameter (defaulting to the module const) so unit tests can drive the
1477
+ * retry without a real 400ms wall-clock wait.
1544
1478
  */
1545
1479
  async function runShellCapture(
1546
1480
  ctx: HandlerContext<any>,
@@ -1551,14 +1485,8 @@ async function runShellCapture(
1551
1485
  ssrModule: SSRModule,
1552
1486
  descriptor: ShellCaptureDescriptor,
1553
1487
  retryDelayMs: number = SHELL_CAPTURE_RETRY_DELAY_MS,
1554
- ): Promise<CaptureAttemptOutcome> {
1555
- const captureUrl = descriptor.navigationOnly ? stripInternalParams(url) : url;
1556
- const captureRequest = descriptor.navigationOnly
1557
- ? createNavigationCaptureRequest(request, captureUrl)
1558
- : request;
1559
- const log = descriptor.debug
1560
- ? (message: string) => console.log(message)
1561
- : () => {};
1488
+ ): Promise<CaptureTaskOutcome> {
1489
+ const key = descriptor.key;
1562
1490
 
1563
1491
  // One attempt + its structured debug event: the stats object rides through
1564
1492
  // attemptCapture/captureAndStoreShell collecting the observability fields
@@ -1567,27 +1495,31 @@ async function runShellCapture(
1567
1495
  // event — scheduleShellCapture's catch publishes the terminal `error` event.
1568
1496
  const timedAttempt = async (
1569
1497
  attempt: number,
1570
- ): Promise<CaptureAttemptOutcome> => {
1498
+ ): Promise<{
1499
+ outcome: CaptureAttemptOutcome;
1500
+ stats: CaptureAttemptStats;
1501
+ }> => {
1571
1502
  const stats: CaptureAttemptStats = {};
1572
1503
  const start = performance.now();
1573
1504
  const outcome = await attemptCapture(
1574
1505
  ctx,
1575
- captureRequest,
1506
+ request,
1576
1507
  env,
1577
- captureUrl,
1508
+ url,
1578
1509
  reqCtx,
1579
1510
  ssrModule,
1580
1511
  descriptor,
1581
1512
  stats,
1582
1513
  );
1514
+ const { noShellCause, pendingStacks, expired, ...eventFields } = stats;
1583
1515
  publishCaptureDebugEvent(descriptor, {
1584
- key: descriptor.key,
1516
+ key,
1585
1517
  outcome,
1586
1518
  attempt,
1587
1519
  attemptMs: Math.round(performance.now() - start),
1588
- ...stats,
1520
+ ...eventFields,
1589
1521
  });
1590
- return outcome;
1522
+ return { outcome, stats };
1591
1523
  };
1592
1524
 
1593
1525
  const taskStartedAt = performance.now();
@@ -1595,9 +1527,11 @@ async function runShellCapture(
1595
1527
  // "refused" is deterministic (identity guard / rejected bake-lane loader —
1596
1528
  // its own warning already fired): no retry, and the caller backs the key off
1597
1529
  // exactly like a structural no-shell.
1598
- if (first === "refused") return "no-shell";
1599
- let second: CaptureAttemptOutcome;
1600
- if (first === "expired") {
1530
+ if (first.outcome === "refused") return "no-shell";
1531
+ if (first.outcome === "stored" || first.outcome === "redirect") {
1532
+ return first.outcome;
1533
+ }
1534
+ if (first.outcome === "expired") {
1601
1535
  // The record attempt 1 read ran out mid-capture: a fresh match reads or
1602
1536
  // renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
1603
1537
  // so a retry that could not finish inside it (barrier + captureTimeout)
@@ -1608,42 +1542,35 @@ async function runShellCapture(
1608
1542
  SHELL_CAPTURE_WRITE_BARRIER_MS +
1609
1543
  (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1610
1544
  if (retryBudgetMs < retryNeedsMs) {
1611
- return expiredTerminal(descriptor.key, "no time was left to retry");
1545
+ return expiredTerminal(first.stats, "no time was left to retry");
1612
1546
  }
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
1547
  } else {
1622
- // "stored" (success) or "redirect" (no shell exists): nothing to retry.
1623
- if (first !== "no-shell") return first;
1624
1548
  // Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
1625
1549
  // handlers may still be running (a match is not cancellable), and a retry
1626
1550
  // would start the same work beside them. A cold-module abort, the retry's
1627
1551
  // reason, ends well inside the deadline.
1628
- if (noShellCauses.has(descriptor.key)) {
1629
- warnNullCaptureOnce(descriptor.key, false);
1552
+ if (first.stats.noShellCause !== undefined) {
1553
+ warnNullCaptureOnce(key, false, first.stats);
1630
1554
  return "no-shell";
1631
1555
  }
1632
-
1633
1556
  // Attempt 1 produced no usable shell. Retry ONCE in place — the first
1634
1557
  // 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
- );
1558
+ // typically completes the shell without another HTTP request.
1642
1559
  await delay(retryDelayMs);
1643
- second = await timedAttempt(2);
1644
1560
  }
1645
- if (second === "refused") return "no-shell";
1646
- if (second !== "no-shell") return second;
1561
+
1562
+ // The retry is the last attempt, so none of its outcomes is retried: an
1563
+ // `expired` retry is terminal like a no-shell one, not handed back to the
1564
+ // scheduler (which would neither back the key off nor warn).
1565
+ const second = await timedAttempt(2);
1566
+ if (second.outcome === "expired") {
1567
+ return expiredTerminal(
1568
+ second.stats,
1569
+ first.outcome === "expired" ? "twice in a row" : "on the in-place retry",
1570
+ );
1571
+ }
1572
+ if (second.outcome === "refused") return "no-shell";
1573
+ if (second.outcome !== "no-shell") return second.outcome;
1647
1574
 
1648
1575
  // Both attempts came back with no usable shell. Cold-start would have healed by
1649
1576
  // now, so the eternal-MISS structural shape (a boundary-less live-loader read;
@@ -1651,31 +1578,10 @@ async function runShellCapture(
1651
1578
  // warn once per key. Ordering matters: because the retry
1652
1579
  // absorbs cold-start, cold-start routes almost never reach this warning. The
1653
1580
  // caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
1654
- log(
1655
- `[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
1656
- );
1657
- warnNullCaptureOnce(descriptor.key, true);
1581
+ warnNullCaptureOnce(key, true, second.stats);
1658
1582
  return "no-shell";
1659
1583
  }
1660
1584
 
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
1585
  /**
1680
1586
  * What the record-first step produced ({@link settleCaptureRecord}):
1681
1587
  * - `record`: the doc record settled; `match` carries its fragments in place
@@ -1728,10 +1634,14 @@ export async function settleCaptureRecord(
1728
1634
  const { derivedCtx, freshHandleStore } = derivation;
1729
1635
  const recording = getRecordingStore(derivedCtx._cacheStore);
1730
1636
 
1731
- // cacheRoute waits for handleStore.settled, which needs the seal.
1637
+ // Seal so handleStore.settled resolves once the tracked handlers settle:
1638
+ // cacheRoute waits for it, and so does the capture's quiesce. It gates only
1639
+ // on tracked handlers, not on deferred handle values
1640
+ // (ctx.use(Handle).defer()), so a defer whose resolver depends on a masked
1641
+ // loader never settles and the wait below runs into the deadline instead.
1732
1642
  freshHandleStore.seal();
1733
1643
  const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
1734
- const settled = await withinDeadline(
1644
+ const settled = await raceDeadline(
1735
1645
  Promise.all([
1736
1646
  freshHandleStore.getData().then(resolveDeferredHandleValues),
1737
1647
  derivation.handlerPushesSettled(),
@@ -1770,9 +1680,7 @@ export async function settleCaptureRecord(
1770
1680
 
1771
1681
  const docKey = derivedCtx._shellImplicitCache?.docKey;
1772
1682
  const record =
1773
- recording && docKey !== undefined
1774
- ? (recording.getRecord("segment", docKey) as CachedEntryData | undefined)
1775
- : undefined;
1683
+ recording && docKey !== undefined ? recording.getRecord(docKey) : undefined;
1776
1684
  if (
1777
1685
  !record ||
1778
1686
  !Array.isArray(record.segments) ||
@@ -1858,7 +1766,6 @@ function recordSegmentsHaveHandles(
1858
1766
  * - _requestTags: a fresh Set. The capture collects its OWN shell tags here —
1859
1767
  * non-loader tags only, since loaders are masked — which is exactly the tag
1860
1768
  * set a shell entry should be invalidatable by (loader tags belong to holes).
1861
- * - _transitionWhen: a fresh [] so the capture's transition gating is its own.
1862
1769
  * - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
1863
1770
  * - _dynamic / dynamic(): an own latch the capture reads.
1864
1771
  * - _metricsStore: undefined so the capture never appends to the foreground's
@@ -1908,25 +1815,26 @@ async function attemptCapture(
1908
1815
  descriptor.navigationOnly ? { request, url } : undefined,
1909
1816
  );
1910
1817
  const { derivedCtx, freshHandleStore } = derivation;
1818
+ // The capture generation starts before matching or any snapshot read
1819
+ // (ShellCacheEntry.createdAt): a tag invalidated after it wins.
1911
1820
  const captureStartedAt = Date.now();
1912
1821
 
1913
1822
  return runWithRequestContext(derivedCtx, async () => {
1914
1823
  // One deadline for the whole capture (see
1915
1824
  // PartialPrerenderProps.captureTimeout): the match (handlers, and the
1916
1825
  // loaders they await), then the record, then the prerender with what is
1917
- // left.
1826
+ // left. A match that loses it keeps running (no abort signal), so the
1827
+ // attempt records a cause and runShellCapture does not retry it.
1918
1828
  const deadline =
1919
1829
  Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1920
- const matched = await withinDeadline(
1830
+ const matched = await raceDeadline(
1921
1831
  ctx.router.match(request, { env }),
1922
1832
  deadline,
1923
1833
  );
1924
1834
  if (!matched.done) {
1925
- noShellCauses.set(
1926
- descriptor.key,
1835
+ stats.noShellCause =
1927
1836
  "the handlers (or a loader a handler awaits) did not return within " +
1928
- "ppr.captureTimeout",
1929
- );
1837
+ "ppr.captureTimeout";
1930
1838
  return "no-shell";
1931
1839
  }
1932
1840
  const match = matched.value;
@@ -1957,7 +1865,7 @@ async function attemptCapture(
1957
1865
  }
1958
1866
  if (settled.kind === "refused") return "refused";
1959
1867
  if (settled.kind === "timeout") {
1960
- noShellCauses.set(descriptor.key, settled.reason);
1868
+ stats.noShellCause = settled.reason;
1961
1869
  return "no-shell";
1962
1870
  }
1963
1871
 
@@ -1989,55 +1897,23 @@ async function attemptCapture(
1989
1897
  const outcome = await captureAndStoreShell(
1990
1898
  ssrModule,
1991
1899
  flightStage.stream,
1992
- freshHandleStore,
1993
1900
  derivedCtx,
1994
1901
  {
1995
1902
  ...descriptor,
1996
1903
  captureTimeout: Math.max(1, deadline - Date.now()),
1997
1904
  },
1998
- stats,
1999
1905
  captureStartedAt,
1906
+ stats,
2000
1907
  );
2001
1908
  if (outcome === "no-shell" && Date.now() >= deadline) {
2002
- noShellCauses.set(
2003
- descriptor.key,
1909
+ stats.noShellCause =
2004
1910
  "the shell render did not finish within what ppr.captureTimeout left " +
2005
- `after the handler output settled (${stats.recordSettleMs}ms)`,
2006
- );
1911
+ `after the handler output settled (${stats.recordSettleMs}ms)`;
2007
1912
  }
2008
1913
  return outcome;
2009
1914
  });
2010
1915
  }
2011
1916
 
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?.();
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
- }
2039
- }
2040
-
2041
1917
  /**
2042
1918
  * The derived capture context and its fresh (mask-funneled) handle store,
2043
1919
  * shared by BOTH shell producers: the runtime background capture
@@ -2086,7 +1962,7 @@ export function deriveShellCaptureContext(
2086
1962
  // before the doc record encodes the handles a HIT replays. A route
2087
1963
  // cache() record's restore (handle-snapshot.ts restoreHandles) lands
2088
1964
  // 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.
1965
+ // as owner, so a HIT restores it as that loader's (pushRestored).
2090
1966
  // - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
2091
1967
  // loaders they await via ctx.use execute at capture (live loaders are
2092
1968
  // masked), their pushes are in the captured HTML (<head> title/meta,
@@ -2095,10 +1971,14 @@ export function deriveShellCaptureContext(
2095
1971
  // settled, thenable-free push from a loader body keeps that loader as
2096
1972
  // `owner` (HandleStore.push), the record stores it
2097
1973
  // (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,
1974
+ // pushRestored. Those copies stand: a promise-carrying bake-lane loader
1975
+ // that runs on the HIT reads the store, so its settled pushes (and those
1976
+ // replayed inside its body) are dropped and only its thenable ones are
1977
+ // added. A loader the route also runs on the live lane is a hole: its
1978
+ // live run replaces its copies (pushReplayed, #936), those of the
1979
+ // dependencies it awaits included (they are credited to it). Nested
1980
+ // thenables stay masked (the promise shape is the liveness
1981
+ // declaration, mask-nested.ts). A deferred (thenable) push,
2102
1982
  // one with masked nested promises, or one outside a loader body (a
2103
1983
  // loader-cache replay names its loader: pushReplayed below) stays
2104
1984
  // tagged (HandleStore.push loaderPush) so the
@@ -2113,6 +1993,24 @@ export function deriveShellCaptureContext(
2113
1993
  // loader body: name that loader for the funnel so the record keeps them
2114
1994
  // under it too.
2115
1995
  let replayOwner: string | undefined;
1996
+ // The live-lane loader whose body a push is made in, at any depth, unless
1997
+ // a bake-lane loader's body is nearer (createMatchContextForFull sets the
1998
+ // lanes before any loader runs).
1999
+ // A replay of a live-lane loader's push (a "use cache" or loader cache()
2000
+ // hit a recapture makes inside a bake-lane body) is that hole's too.
2001
+ const liveLaneOwner = (): string | undefined => {
2002
+ const lanes = derivedCtx._shellCaptureLoaderLanes;
2003
+ if (!lanes) return undefined;
2004
+ return (
2005
+ findEnclosingLoaderBody(
2006
+ (id) => lanes.get(id) === "live",
2007
+ (id) => lanes.get(id) === "bake",
2008
+ ) ??
2009
+ (replayOwner !== undefined && lanes.get(replayOwner) === "live"
2010
+ ? replayOwner
2011
+ : undefined)
2012
+ );
2013
+ };
2116
2014
  const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
2117
2015
  freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
2118
2016
  replayOwner = loaderId;
@@ -2154,8 +2052,16 @@ export function deriveShellCaptureContext(
2154
2052
  // material (see the funnel comment above): only bake-lane loaders
2155
2053
  // and the loaders they await run at capture, and a HIT does not run
2156
2054
  // 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;
2055
+ // keeps the push under the loader that made it. A push under a
2056
+ // live-lane loader's body, at any depth, is that hole's output: it
2057
+ // is credited to the hole, whose live run replaces it on a HIT and
2058
+ // a navigation. The first loader around the push that the route
2059
+ // registers decides: a bake-lane one keeps the push under the
2060
+ // innermost body, and a dependency on neither lane is walked past. A
2061
+ // replay of a hole's push is that hole's, unless a live-lane body
2062
+ // around it takes it first.
2063
+ const bodyLoaderId =
2064
+ liveLaneOwner() ?? getCurrentLoaderBodyId() ?? replayOwner;
2159
2065
  loaderPush = bodyLoaderId === undefined;
2160
2066
  if (!loaderPush) owner = bodyLoaderId;
2161
2067
  }
@@ -2203,7 +2109,6 @@ export function deriveShellCaptureContext(
2203
2109
  // the whole capture snapshot in memory. Capture registrations die with this
2204
2110
  // context; module-singleton stores stay registered by normal renders.
2205
2111
  derivedCtx._explicitTaggedStores = new Set();
2206
- derivedCtx._transitionWhen = [];
2207
2112
  // Own list: the capture's render errors refuse the capture only, and the
2208
2113
  // foreground's never reach it.
2209
2114
  derivedCtx._renderErrors = [];
@@ -2228,31 +2133,32 @@ export function deriveShellCaptureContext(
2228
2133
  // foreground rango.request span ended (orphan spans in the trace).
2229
2134
  // _tracing is otherwise inherited through Object.create(reqCtx).
2230
2135
  derivedCtx._tracing = undefined;
2231
- // Bake-lane loader containers (loaders on entries with no renderable
2232
- // loading() execute during capture — docs/design/loader-container-bake.md).
2233
- // resolveLoaderData registers each container promise here; the drain in
2234
- // captureAndStoreShell elides + pins them into the snapshot's loader family.
2136
+ // Bake-lane (`ssr: false`) loader containers, which execute during capture
2137
+ // (docs/design/loader-container-bake.md). resolveLoaderData registers each
2138
+ // container promise here; settleCaptureRecord waits for them, and the drain
2139
+ // in captureAndStoreShell elides + pins them into the snapshot's loader
2140
+ // family.
2235
2141
  derivedCtx._shellCaptureLoaderRecords = new Map();
2236
- // Own onResponse list so the capture's match-middleware callbacks (the ring-3
2237
- // segment cache write registers here) are ISOLATED from the foreground's shared
2238
- // array AND can be fired by captureAndStoreShell. The segment write is gated
2239
- // behind onResponse, which the capture never triggers (it builds no Response) —
2240
- // without firing it, a ring-3 cache() MISS at capture renders fresh into the
2241
- // prelude but is never written, so it is never recorded and drifts on a HIT.
2142
+ // Own onResponse list so the capture's match-middleware callbacks (the doc
2143
+ // record registers here) are ISOLATED from the foreground's shared array AND
2144
+ // can be fired by settleCaptureRecord. The segment write is gated behind
2145
+ // onResponse, which the capture never triggers (it builds no Response):
2146
+ // without the synthetic fire, no doc record is written.
2242
2147
  derivedCtx._onResponseCallbacks = [];
2243
2148
 
2244
- // Capture data snapshot: read every cache-store hit/write through a recording
2245
- // wrapper on the DERIVED context's store (own property, so the shared
2246
- // reqCtx._cacheStore is untouched — the snapshot is per-capture). Its records
2247
- // ride inside the ShellCacheEntry so a HIT can reproduce the shell's cached
2248
- // content byte-identically. See cache/shell-snapshot.ts and the design doc.
2149
+ // Capture data snapshot: route the capture's cache-store calls through a
2150
+ // recording wrapper on the DERIVED context's store (own property, so the
2151
+ // shared reqCtx._cacheStore is untouched — the snapshot is per-capture). It
2152
+ // records the doc segment record, which rides inside the ShellCacheEntry
2153
+ // with the bake-lane loader pins; no cache read is recorded. See
2154
+ // cache/shell-snapshot.ts and the design doc.
2249
2155
  //
2250
- // Cache writes are deferred (waitUntil): a MISS-at-capture value's setItem/set
2251
- // — hence its record — would otherwise land after the shell quiesces. Override
2252
- // the derived context's waitUntil to COLLECT those write promises (still
2253
- // forwarding to the parent so the write persists and the worker stays alive),
2254
- // then captureAndStoreShell awaits them before draining. Reads that HIT are
2255
- // recorded synchronously during the render and need none of this.
2156
+ // Cache writes are deferred (waitUntil): the doc record's write would
2157
+ // otherwise land after the match returns. Override the derived context's
2158
+ // waitUntil to COLLECT those write promises (still forwarding to the parent
2159
+ // so the write persists and the worker stays alive), then
2160
+ // settleCaptureRecord and captureAndStoreShell await them before reading
2161
+ // the record and draining.
2256
2162
  if (reqCtx._cacheStore) {
2257
2163
  const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
2258
2164
  derivedCtx._cacheStore = recordingStore;
@@ -2285,13 +2191,17 @@ export function deriveShellCaptureContext(
2285
2191
  }
2286
2192
 
2287
2193
  /**
2288
- * Seal handles, derive the quiesce signal, prerender + abort via the SSR module's
2289
- * captureShellHTML, and store the result. Returns the attempt outcome (the caller
2290
- * owns retry/warn decisions — this function no longer warns). Never throws out of
2291
- * the store write: a failed putShell is routed through reportCacheError so the
2292
- * background task stays best-effort, and the attempt still counts as `stored` (the
2293
- * capture worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
2294
- * present (eligibility is checked before scheduling).
2194
+ * Derive the quiesce signal, prerender + abort via the SSR module's
2195
+ * captureShellHTML, and store the result. Runs after settleCaptureRecord (both
2196
+ * producers), which sealed the capture's handle store (`reqCtx._handleStore`)
2197
+ * and waited for everything the shell bakes. Returns the attempt outcome (the
2198
+ * caller owns retry/warn decisions). Never throws out of the store write: a
2199
+ * failed putShell is routed through reportCacheError so the background task
2200
+ * stays best-effort, and the attempt still counts as `stored` (the capture
2201
+ * worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
2202
+ * present (eligibility is checked before scheduling). `captureStartedAt` is the
2203
+ * capture generation's start, stamped before the match (ShellCacheEntry
2204
+ * .createdAt).
2295
2205
  *
2296
2206
  * A `no-shell` result from captureShellHTML is the only retryable outcome. Every
2297
2207
  * captureShellHTML error propagates to reportCacheError and is NOT retried. The
@@ -2301,87 +2211,17 @@ export function deriveShellCaptureContext(
2301
2211
  async function captureAndStoreShell(
2302
2212
  ssrModule: SSRModule,
2303
2213
  rscStream: ReadableStream<Uint8Array>,
2304
- handleStore: HandleStore,
2305
2214
  reqCtx: RequestContext<any>,
2306
2215
  capture: ShellCaptureDescriptor,
2307
- stats?: CaptureAttemptStats,
2308
- captureStartedAt: number = Date.now(),
2216
+ captureStartedAt: number,
2217
+ stats: CaptureAttemptStats = {},
2309
2218
  ): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
2310
2219
  const captureShellHTML = ssrModule.captureShellHTML!;
2311
2220
 
2312
- // Seal the handle store so the payload's handles generator (resolvedHandleStream
2313
- // -> handleStore.stream()) converges and completes even though masked loaders
2314
- // never resolve. handleStore.settled gates ONLY on tracked HANDLER promises
2315
- // (handleStore.track, via trackHandler) — NOT on deferred handle VALUES pushed
2316
- // through ctx.use(Handle).defer(), which are plain pushed promises. So seal()
2317
- // does not reject or hang on outstanding defers: settled resolves once the
2318
- // handlers settle, and each deferred slot resolves on its own createDeferred
2319
- // timeout (defer.ts, default 10s) or when its resolver fires. A defer whose
2320
- // resolver depends on a masked loader can never fire, so it stays pending until
2321
- // that 10s timeout — longer than maxWaitMs (5s). At the abort the handles
2322
- // generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
2323
- // sits above every boundary), the prelude comes back trivial, and
2324
- // captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
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").
2328
- handleStore.seal();
2329
-
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).
2343
- const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
2344
- // Bake-lane loader containers hold the gate the same way (loader-container-
2345
- // bake): a boundary-less container with real latency (a 100ms layout loader)
2346
- // would otherwise lose the 2-hop byte-quiet race — the pending loaderData row
2347
- // emits no bytes, the gate freezes, and the awaiting tree pins above <body>.
2348
- // The records map is fully populated before this point (loader promises are
2349
- // created during the capture's match()), so the hold covers every bake-lane
2350
- // container. allSettled: a REJECTED container releases the hold (the drain
2351
- // below refuses the capture); nested promises INSIDE a container never hold
2352
- // the gate — they stay holes. Bounded by maxWaitMs like every quiesce input.
2353
- const loaderRecordsForHold = reqCtx._shellCaptureLoaderRecords;
2354
- const holdUntil =
2355
- loaderRecordsForHold && loaderRecordsForHold.size > 0
2356
- ? Promise.allSettled([handlesBaked, ...loaderRecordsForHold.values()])
2357
- : handlesBaked;
2358
- // Bake-cost attribution: record how long each bake source held the gate,
2359
- // measured from capture start. Side-channel observers only — holdUntil and
2360
- // its consumers are untouched, and a rejected source is recorded the same
2361
- // (allSettled swallows it; the drain below owns the refusal). Folded into
2362
- // stats.bakeWaitMs (+ a once-per-key dev warning) after a successful
2363
- // capture; on a refusal the other warnings own the story.
2364
- const bakeStart = performance.now();
2365
- const bakeWaits: { source: string; ms: number }[] = [];
2366
- const observeBake = (source: string, promise: Promise<unknown>): void => {
2367
- const record = (): void => {
2368
- bakeWaits.push({
2369
- source,
2370
- ms: Math.round(performance.now() - bakeStart),
2371
- });
2372
- };
2373
- promise.then(record, record);
2374
- };
2375
- observeBake("top-level pushed handle promises", handlesBaked);
2376
- if (loaderRecordsForHold) {
2377
- for (const [loaderId, promise] of loaderRecordsForHold) {
2378
- observeBake(`bake-lane segment loader "${loaderId}"`, promise);
2379
- }
2380
- }
2381
- const gate = gateFlightForCapture(rscStream, undefined, holdUntil);
2221
+ const gate = gateFlightForCapture(rscStream);
2382
2222
  // Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
2383
2223
  // half stalling is bounded by captureShellHTML's maxWaitMs.
2384
- const quiesce = Promise.all([handleStore.settled, gate.quiesce]).then(
2224
+ const quiesce = Promise.all([reqCtx._handleStore.settled, gate.quiesce]).then(
2385
2225
  () => {},
2386
2226
  );
2387
2227
 
@@ -2400,7 +2240,6 @@ async function captureAndStoreShell(
2400
2240
  | ((errorInfo: { componentStack?: string } | undefined) => void)
2401
2241
  | undefined;
2402
2242
  if (process.env.NODE_ENV !== "production") {
2403
- noShellPendingStacks.delete(capture.key);
2404
2243
  const stacks: string[] = [];
2405
2244
  pendingStacks = stacks;
2406
2245
  onAbortedTask = (errorInfo) => {
@@ -2457,72 +2296,24 @@ async function captureAndStoreShell(
2457
2296
  if (refused) return refused;
2458
2297
 
2459
2298
  if (result === null) {
2460
- if (pendingStacks) noShellPendingStacks.set(capture.key, pendingStacks);
2299
+ stats.pendingStacks = pendingStacks;
2461
2300
  return "no-shell";
2462
2301
  }
2463
- if (stats) stats.preludeBytes = result.prelude.length;
2464
-
2465
- // Every bake source has settled by here (quiesce gates on holdUntil).
2466
- // Surface the slowest one: sub-ms settles are noise, threshold-crossers
2467
- // get the dev warning with the remedy ladder.
2468
- let slowestBake: { source: string; ms: number } | undefined;
2469
- for (const wait of bakeWaits) {
2470
- if (!slowestBake || wait.ms > slowestBake.ms) slowestBake = wait;
2471
- }
2472
- if (slowestBake && slowestBake.ms > 0) {
2473
- if (stats) stats.bakeWaitMs = slowestBake.ms;
2474
- if (
2475
- process.env.NODE_ENV !== "production" &&
2476
- slowestBake.ms >= SHELL_CAPTURE_BAKE_WARN_MS
2477
- ) {
2478
- warnBakeCostOnce(capture.key, slowestBake.source, slowestBake.ms);
2479
- }
2480
- }
2481
-
2482
- // Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
2483
- // threads the SAME store it resolved for its getShell read (options.store ??
2484
- // _cacheStore), so a store-attached middleware writes captures where it reads
2485
- // them. The _cacheStore fallback covers a flag armed without a store (tests).
2486
- // reactVersion is read from the same React.version import the middleware
2487
- // validates reads against, so capture and serve always agree.
2488
- // Fire the capture's isolated onResponse callbacks with a synthetic 200 so
2489
- // the ring-3 segment cache write (cacheScope.cacheRoute, registered via
2490
- // onResponse by the cache-store match-middleware and gated on a 200) runs
2491
- // DURING capture, routed through the recording store. The foreground path
2492
- // never fires for the capture — it builds no Response — so without this a
2493
- // cache() SEGMENT that MISSED at capture would be rendered fresh into the
2494
- // prelude yet never written, hence never recorded, and would drift on a HIT
2495
- // (an item-family "use cache" write already runs inline during the render, so
2496
- // it needs none of this; only segment writes are onResponse-gated). The
2497
- // derived context's own _onResponseCallbacks holds only capture match-
2498
- // middleware callbacks (HTTP middleware never runs for a capture), so firing
2499
- // them is safe. Best-effort: a throwing callback must not fail the capture.
2500
- const responseCallbacks = reqCtx._onResponseCallbacks;
2501
- if (responseCallbacks && responseCallbacks.length > 0) {
2502
- const synthetic = new Response(null, { status: 200 });
2503
- for (const cb of responseCallbacks) {
2504
- try {
2505
- cb(synthetic);
2506
- } catch {
2507
- // A capture-time cache write that throws is degradation, not failure.
2508
- }
2509
- }
2510
- }
2511
-
2512
- // Drain the capture data snapshot from the recording store on the derived
2513
- // context. Await the deferred cache writes first so a MISS-at-capture value
2514
- // (setItem/set scheduled under waitUntil, including the segment write just
2515
- // fired) is pinned, not just read-hits. When no recording store is installed
2516
- // (unit tests that call this directly), there is simply no snapshot.
2302
+ stats.preludeBytes = result.prelude.length;
2303
+
2304
+ // Drain the doc record from the recording store on the derived context.
2305
+ // The capture's own deferred cache writes ("use cache" and loader
2306
+ // cache() misses made while it rendered) settle first, bounded, so the
2307
+ // next HIT's holes find them in the store. When no recording store is
2308
+ // installed (unit tests that call this directly), the snapshot starts
2309
+ // empty.
2517
2310
  const recording = getRecordingStore(reqCtx._cacheStore);
2518
- let snapshot: ShellSnapshotRecord[] | undefined;
2311
+ let snapshot: ShellSnapshotRecord[] = [];
2519
2312
  if (recording) {
2520
2313
  const settleStart = performance.now();
2521
2314
  await recording.settleWrites(SHELL_SNAPSHOT_WRITE_SETTLE_MS);
2522
- if (stats) {
2523
- stats.writeSettleMs = Math.round(performance.now() - settleStart);
2524
- }
2525
- snapshot = recording.drainSnapshot();
2315
+ stats.writeSettleMs = Math.round(performance.now() - settleStart);
2316
+ snapshot = recording.drainSnapshot() ?? [];
2526
2317
  }
2527
2318
 
2528
2319
  // Pin the bake-lane loader containers (loader family). Settled containers
@@ -2591,7 +2382,7 @@ async function captureAndStoreShell(
2591
2382
  reqCtx._renderErrors?.push(error);
2592
2383
  });
2593
2384
  if (serialized !== null) {
2594
- (snapshot ??= []).push({
2385
+ snapshot.push({
2595
2386
  family: "loader",
2596
2387
  key: segmentKey,
2597
2388
  // The hole bit rides with the record so the HIT overlay knows
@@ -2622,36 +2413,28 @@ async function captureAndStoreShell(
2622
2413
  const renderErrors = reqCtx._renderErrors;
2623
2414
  if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
2624
2415
 
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.
2416
+ // Store only what a HIT reads (issue #941): the doc record and, for a
2417
+ // document entry, the bake-lane loader pins. Before the size guards, so
2418
+ // they measure what is stored.
2628
2419
  const docKey = reqCtx._shellImplicitCache?.docKey;
2629
2420
  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;
2642
- }
2643
- }
2421
+ const { kept, pruned } = pruneShellSnapshot(
2422
+ snapshot,
2423
+ capture.navigationOnly === true,
2424
+ docKey,
2425
+ );
2426
+ if (pruned.length > 0) {
2427
+ prunedRecords = countSnapshotFamilies(pruned);
2428
+ snapshot = kept;
2644
2429
  }
2645
2430
 
2646
- // Snapshot size guard (issue #651): the snapshot duplicates every pinned
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
2431
+ // Snapshot size guard (issue #651): `maxSnapshotBytes` bounds the loader
2432
+ // pins; over it they are dropped and the entry keeps its doc record, so
2433
+ // every HIT still replays the handler layer (the pins' loaders then run
2434
+ // on the HIT: documented drift, repaired client-side). The doc record is
2652
2435
  // exempt: without it a HIT could not serve at all. The whole entry is
2653
2436
  // bounded separately below, by the store's value limit.
2654
- if (snapshot && snapshot.length > 0) {
2437
+ if (snapshot.length > 0) {
2655
2438
  const docRecord =
2656
2439
  docKey !== undefined
2657
2440
  ? snapshot.find((r) => r.family === "segment" && r.key === docKey)
@@ -2663,28 +2446,20 @@ async function captureAndStoreShell(
2663
2446
  pins.length > 0
2664
2447
  ? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
2665
2448
  : 0;
2666
- if (stats) stats.snapshotBytes = pinBytes;
2449
+ stats.snapshotBytes = pinBytes;
2667
2450
  const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
2668
2451
  if (pinBytes > cap) {
2669
2452
  warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
2670
- snapshot = docRecord ? [docRecord] : undefined;
2671
- if (stats) stats.snapshotSkipped = true;
2453
+ snapshot = docRecord ? [docRecord] : [];
2454
+ stats.snapshotSkipped = true;
2672
2455
  }
2673
2456
  }
2674
2457
 
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.
2458
+ // settleCaptureRecord refused every capture without a doc record except a
2459
+ // prerender-served one (the prerender store supplies its handler layer on
2460
+ // every HIT), and the record survives the drain, the pruning and the size
2461
+ // cap above.
2679
2462
  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
2463
 
2689
2464
  // Shell tags: what the shell renders from, and nothing else. The handler
2690
2465
  // layer is the doc record, whose tags are what its content recorded (the
@@ -2701,7 +2476,7 @@ async function captureAndStoreShell(
2701
2476
  // without a doc record (the prerender store served its match) keeps the
2702
2477
  // request-level set. Union with the route's static ppr.tags.
2703
2478
  const docRecordValue = storedDocKey
2704
- ? (snapshot?.find((r) => r.family === "segment" && r.key === storedDocKey)
2479
+ ? (snapshot.find((r) => r.family === "segment" && r.key === storedDocKey)
2705
2480
  ?.value as CachedEntryData | undefined)
2706
2481
  : undefined;
2707
2482
  const collected = docRecordValue
@@ -2726,9 +2501,9 @@ async function captureAndStoreShell(
2726
2501
  snapshot,
2727
2502
  tags: shellTags,
2728
2503
  docKey: storedDocKey,
2729
- prunedRecords: snapshot ? prunedRecords : undefined,
2504
+ prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
2730
2505
  });
2731
- if (stats) stats.entryBytes = entryBytes;
2506
+ stats.entryBytes = entryBytes;
2732
2507
  const entryLimit =
2733
2508
  (capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
2734
2509
  DEFAULT_SHELL_ENTRY_MAX_BYTES;
@@ -2745,12 +2520,7 @@ async function captureAndStoreShell(
2745
2520
 
2746
2521
  // Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
2747
2522
  // that choice only to operators who enabled structured capture diagnostics.
2748
- if (
2749
- capture.debugSink &&
2750
- stats &&
2751
- bakedLoaderMaterial &&
2752
- shellTags === undefined
2753
- ) {
2523
+ if (capture.debugSink && bakedLoaderMaterial && shellTags === undefined) {
2754
2524
  stats.untaggedBake = true;
2755
2525
  }
2756
2526
 
@@ -2779,7 +2549,7 @@ async function captureAndStoreShell(
2779
2549
  // matches the frozen prelude — see ShellCacheEntry.initialTheme.
2780
2550
  initialTheme: payloadInitialTheme(reqCtx),
2781
2551
  snapshot,
2782
- prunedRecords: snapshot ? prunedRecords : undefined,
2552
+ prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
2783
2553
  // The canonical doc segment record's key, published by the doc
2784
2554
  // scope's cacheRoute during this capture's match. Every HIT tail
2785
2555
  // looks the record up by this key (serveShellHit fixedDocKey);
@@ -2819,11 +2589,11 @@ async function captureAndStoreShell(
2819
2589
  );
2820
2590
  return "refused";
2821
2591
  }
2822
- expiredRecords.set(capture.key, {
2592
+ stats.expired = {
2823
2593
  route,
2824
2594
  record,
2825
2595
  captureMs: Date.now() - captureStartedAt,
2826
- });
2596
+ };
2827
2597
  return "expired";
2828
2598
  }
2829
2599
  window = capped;
@@ -2835,7 +2605,7 @@ async function captureAndStoreShell(
2835
2605
  window.swr,
2836
2606
  shellTags,
2837
2607
  );
2838
- if (stats && storeWrite) stats.storeWrite = storeWrite;
2608
+ if (storeWrite) stats.storeWrite = storeWrite;
2839
2609
  if (storeWrite === "invalidated") {
2840
2610
  warnCaptureRefusedOnce(
2841
2611
  capture.key,
@@ -2908,22 +2678,15 @@ export {
2908
2678
 
2909
2679
  /**
2910
2680
  * @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.
2681
+ * guard, the refused-capture backoff, the once-per-key warnings (the shell
2682
+ * path's one registry), the stored-capture sequence and the buffered debug
2683
+ * events. Tests only (testing/serve-shell-request.ts resetShellTestState),
2684
+ * between requests, never while a capture runs.
2915
2685
  */
2916
2686
  export function resetShellCaptureStateForTests(): void {
2917
2687
  inFlightCaptures.clear();
2918
2688
  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();
2689
+ resetShellWarningsForTests();
2927
2690
  storedCaptureSeqs.clear();
2928
2691
  lastCaptureEventsForTiming.clear();
2929
2692
  }