@rangojs/router 0.27.0 → 0.29.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 (243) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-fence.d.ts +2 -1
  4. package/dist/types/browser/event-controller.d.ts +8 -2
  5. package/dist/types/browser/history-state.d.ts +3 -9
  6. package/dist/types/browser/navigation-store.d.ts +8 -6
  7. package/dist/types/browser/partial-update.d.ts +11 -0
  8. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  9. package/dist/types/browser/react/use-action.d.ts +7 -2
  10. package/dist/types/browser/react/use-handle.d.ts +19 -16
  11. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  12. package/dist/types/browser/segment-reconciler.d.ts +32 -4
  13. package/dist/types/browser/types.d.ts +24 -5
  14. package/dist/types/browser/wire-protocol.d.ts +26 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  16. package/dist/types/cache/cache-scope.d.ts +34 -22
  17. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  19. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  20. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  21. package/dist/types/cache/segment-codec.d.ts +14 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +5 -14
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +11 -5
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +6 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +13 -22
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +34 -86
  38. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  39. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/router-context.d.ts +7 -46
  44. package/dist/types/router/router-interfaces.d.ts +8 -14
  45. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +95 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  52. package/dist/types/router/segment-resolution.d.ts +1 -2
  53. package/dist/types/router/segment-wrappers.d.ts +11 -48
  54. package/dist/types/router/telemetry.d.ts +2 -0
  55. package/dist/types/router/types.d.ts +11 -1
  56. package/dist/types/rsc/full-payload.d.ts +2 -2
  57. package/dist/types/rsc/handler-context.d.ts +6 -6
  58. package/dist/types/rsc/json-route-result.d.ts +5 -9
  59. package/dist/types/rsc/request-stages.d.ts +203 -0
  60. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  61. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  62. package/dist/types/rsc/routine-plan.d.ts +1 -1
  63. package/dist/types/rsc/server-action.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  65. package/dist/types/rsc/shell-capture.d.ts +132 -15
  66. package/dist/types/rsc/shell-serve.d.ts +37 -12
  67. package/dist/types/server/context.d.ts +9 -8
  68. package/dist/types/server/handle-store.d.ts +16 -0
  69. package/dist/types/server/render-pass.d.ts +72 -0
  70. package/dist/types/server/request-context.d.ts +53 -104
  71. package/dist/types/server/rewrite-trail.d.ts +2 -2
  72. package/dist/types/testing/dispatch.d.ts +35 -18
  73. package/dist/types/testing/render-route.d.ts +43 -8
  74. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  75. package/dist/types/types/cache-types.d.ts +6 -3
  76. package/dist/types/types/error-types.d.ts +30 -3
  77. package/dist/types/types/handler-context.d.ts +9 -1
  78. package/dist/types/types/segments.d.ts +1 -1
  79. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  80. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  81. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  82. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  83. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  84. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  85. package/dist/types/vite/discovery/state.d.ts +12 -0
  86. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  87. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  88. package/dist/types/vite/router-discovery.d.ts +8 -47
  89. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  90. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  91. package/dist/types/wire-params.d.ts +39 -0
  92. package/dist/vite/index.js +3822 -3727
  93. package/package.json +1 -1
  94. package/skills/cache-guide/SKILL.md +8 -5
  95. package/skills/caching/SKILL.md +37 -18
  96. package/skills/client-urls/SKILL.md +4 -2
  97. package/skills/document-cache/SKILL.md +13 -1
  98. package/skills/handler-use/SKILL.md +1 -1
  99. package/skills/hooks/SKILL.md +1 -1
  100. package/skills/hooks/data.md +8 -1
  101. package/skills/hooks/handle-and-actions.md +33 -2
  102. package/skills/hooks/navigation.md +121 -15
  103. package/skills/hooks/state.md +6 -4
  104. package/skills/hooks/urls.md +16 -5
  105. package/skills/intercept/SKILL.md +23 -7
  106. package/skills/middleware/SKILL.md +18 -0
  107. package/skills/observability/SKILL.md +30 -0
  108. package/skills/ppr/SKILL.md +18 -3
  109. package/skills/prerender/SKILL.md +39 -15
  110. package/skills/rango/SKILL.md +5 -1
  111. package/skills/server-actions/SKILL.md +15 -1
  112. package/skills/testing/client-components.md +25 -18
  113. package/skills/testing/response-routes.md +6 -4
  114. package/skills/testing/serve-shell-request.md +7 -7
  115. package/skills/use-cache/SKILL.md +46 -8
  116. package/skills/view-transitions/SKILL.md +1 -1
  117. package/src/__internal.ts +7 -1
  118. package/src/browser/action-fence.ts +2 -1
  119. package/src/browser/connection-warmup.ts +3 -1
  120. package/src/browser/event-controller.ts +37 -13
  121. package/src/browser/history-state.ts +6 -12
  122. package/src/browser/navigation-bridge.ts +59 -26
  123. package/src/browser/navigation-client.ts +20 -25
  124. package/src/browser/navigation-store.ts +25 -27
  125. package/src/browser/navigation-transaction.ts +4 -1
  126. package/src/browser/network-error-handler.ts +4 -0
  127. package/src/browser/partial-update.ts +33 -6
  128. package/src/browser/prefetch/cache.ts +2 -1
  129. package/src/browser/prefetch/fetch.ts +8 -16
  130. package/src/browser/prefetch/queue.ts +4 -4
  131. package/src/browser/react/NavigationProvider.tsx +2 -1
  132. package/src/browser/react/filter-segment-order.ts +2 -2
  133. package/src/browser/react/use-action.ts +24 -48
  134. package/src/browser/react/use-handle.ts +22 -18
  135. package/src/browser/react/use-link-status.ts +48 -60
  136. package/src/browser/react/use-navigation.ts +92 -41
  137. package/src/browser/rsc-router.tsx +1 -0
  138. package/src/browser/segment-reconciler.ts +109 -53
  139. package/src/browser/server-action-bridge.ts +81 -61
  140. package/src/browser/types.ts +25 -5
  141. package/src/browser/wire-protocol.ts +43 -0
  142. package/src/cache/cache-key-utils.ts +11 -5
  143. package/src/cache/cache-runtime.ts +132 -75
  144. package/src/cache/cache-scope.ts +110 -64
  145. package/src/cache/cf/cf-cache-constants.ts +2 -2
  146. package/src/cache/cf/cf-cache-store.ts +486 -874
  147. package/src/cache/cf/cf-kv-utils.ts +1 -1
  148. package/src/cache/document-cache.ts +64 -46
  149. package/src/cache/handle-snapshot.ts +19 -7
  150. package/src/cache/read-through-swr.ts +20 -18
  151. package/src/cache/segment-codec.ts +55 -0
  152. package/src/cache/shell-snapshot.ts +2 -2
  153. package/src/cache/tag-invalidation.ts +6 -2
  154. package/src/cache/taint.ts +5 -3
  155. package/src/cache/types.ts +7 -3
  156. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  157. package/src/handles/pushed-by.ts +1 -1
  158. package/src/prefetch-markers.ts +5 -16
  159. package/src/prerender/store.ts +76 -0
  160. package/src/prerender/warm-request.ts +8 -4
  161. package/src/prerender.ts +2 -2
  162. package/src/route-name.ts +8 -0
  163. package/src/router/error-handling.ts +22 -10
  164. package/src/router/instrument.ts +4 -3
  165. package/src/router/intercept-resolution.ts +82 -75
  166. package/src/router/loader-resolution.ts +5 -4
  167. package/src/router/match-api.ts +5 -5
  168. package/src/router/match-context.ts +23 -12
  169. package/src/router/match-handlers.ts +8 -20
  170. package/src/router/match-middleware/background-revalidation.ts +46 -57
  171. package/src/router/match-middleware/cache-lookup.ts +677 -726
  172. package/src/router/match-middleware/cache-store.ts +4 -8
  173. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  174. package/src/router/match-middleware/segment-resolution.ts +34 -61
  175. package/src/router/middleware.ts +69 -11
  176. package/src/router/navigation-snapshot.ts +14 -4
  177. package/src/router/prerender-match.ts +100 -44
  178. package/src/router/preview-match.ts +6 -41
  179. package/src/router/request-classification.ts +13 -7
  180. package/src/router/route-snapshot.ts +2 -5
  181. package/src/router/router-context.ts +10 -95
  182. package/src/router/router-interfaces.ts +8 -14
  183. package/src/router/segment-resolution/helpers.ts +49 -13
  184. package/src/router/segment-resolution/loader-cache.ts +15 -9
  185. package/src/router/segment-resolution/loader-mask.ts +5 -5
  186. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  187. package/src/router/segment-resolution/resolution-pass.ts +139 -0
  188. package/src/router/segment-resolution/revalidation.ts +939 -789
  189. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  190. package/src/router/segment-resolution.ts +4 -13
  191. package/src/router/segment-wrappers.ts +51 -269
  192. package/src/router/telemetry.ts +5 -0
  193. package/src/router/types.ts +11 -1
  194. package/src/router.ts +19 -17
  195. package/src/rsc/full-payload.ts +6 -5
  196. package/src/rsc/handler-context.ts +8 -14
  197. package/src/rsc/handler.ts +126 -507
  198. package/src/rsc/json-route-result.ts +5 -9
  199. package/src/rsc/loader-fetch.ts +6 -5
  200. package/src/rsc/progressive-enhancement.ts +5 -0
  201. package/src/rsc/request-stages.ts +759 -0
  202. package/src/rsc/response-cache-serve.ts +10 -14
  203. package/src/rsc/response-route-handler.ts +13 -5
  204. package/src/rsc/rewrite-passes.ts +2 -1
  205. package/src/rsc/routine-plan.ts +1 -1
  206. package/src/rsc/rsc-rendering.ts +164 -349
  207. package/src/rsc/server-action.ts +87 -113
  208. package/src/rsc/shell-capture-constants.ts +1 -1
  209. package/src/rsc/shell-capture.ts +275 -227
  210. package/src/rsc/shell-serve.ts +121 -18
  211. package/src/rsc/ssr-setup.ts +5 -4
  212. package/src/segment-system.tsx +3 -3
  213. package/src/server/context.ts +22 -25
  214. package/src/server/handle-store.ts +55 -0
  215. package/src/server/render-pass.ts +291 -0
  216. package/src/server/request-context.ts +75 -115
  217. package/src/server/rewrite-trail.ts +6 -4
  218. package/src/ssr/index.tsx +1 -1
  219. package/src/testing/dispatch.ts +226 -710
  220. package/src/testing/e2e/page-helpers.ts +2 -2
  221. package/src/testing/render-route.tsx +199 -13
  222. package/src/testing/serve-shell-request.ts +36 -40
  223. package/src/types/cache-types.ts +6 -3
  224. package/src/types/error-types.ts +30 -3
  225. package/src/types/handler-context.ts +12 -1
  226. package/src/types/segments.ts +1 -1
  227. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  228. package/src/vite/discovery/build-env.ts +148 -0
  229. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  230. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  231. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  232. package/src/vite/discovery/discover-routers.ts +1 -1
  233. package/src/vite/discovery/discovery-errors.ts +1 -1
  234. package/src/vite/discovery/prerender-collection.ts +24 -39
  235. package/src/vite/discovery/state.ts +15 -0
  236. package/src/vite/plugins/version-plugin.ts +1 -1
  237. package/src/vite/plugins/virtual-entries.ts +1 -1
  238. package/src/vite/router-discovery.ts +29 -2428
  239. package/src/vite/temp-rsc-server.ts +223 -0
  240. package/src/vite/utils/prerender-utils.ts +47 -0
  241. package/src/wire-params.ts +40 -0
  242. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  243. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -4,9 +4,9 @@
4
4
  * Capture does NOT flow through the HTTP middleware pipeline. The integrated PPR
5
5
  * serve path (rsc-rendering.ts + shell-serve.ts) builds a ShellCaptureDescriptor
6
6
  * from the route's `ppr` path option after the served response is built and calls
7
- * scheduleShellCapture. The capture then runs as a background task that re-derives
7
+ * planShellCapture with it (ShellCaptureInput). 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 live-lane loaders mask (loader-mask.ts)
9
+ * handle store, `_shellCaptureState.run` so live-lane loaders mask (loader-mask.ts)
10
10
  * and postpone at their loading() or inline <Suspense> boundary. The match is
11
11
  * MIXED-CHAIN: cache()'d segments replay from ring 3, uncached segments execute
12
12
  * their handlers. The record-first step (settleCaptureRecord) waits for the
@@ -48,13 +48,13 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
48
48
  import { observePhase, PHASES } from "../router/instrument.js";
49
49
  import type { TraceSpan } from "../router/tracing.js";
50
50
  import {
51
+ runAsBackgroundWork,
51
52
  runWithRequestContext,
52
53
  setRequestContextParams,
53
- wireRenderBarrier,
54
54
  UNTRACKED_BACKGROUND_TASK,
55
55
  type RequestContext,
56
- type RouteRecordWindow,
57
56
  } from "../server/request-context.js";
57
+ import { deriveRenderPass } from "../server/render-pass.js";
58
58
  import {
59
59
  DEFAULT_FUNCTION_TTL,
60
60
  resolveSwrWindow,
@@ -149,7 +149,7 @@ const SHELL_SNAPSHOT_WRITE_SETTLE_MS = 1000;
149
149
  * Upper bound on the pre-render WRITE BARRIER: before the capture's match/render,
150
150
  * settle the background tasks the FOREGROUND request already scheduled — its
151
151
  * deferred ring-3 cacheRoute and ring-1 setItem writes all go through
152
- * reqCtx.waitUntil, and every one of them is scheduled BEFORE scheduleShellCapture
152
+ * reqCtx.waitUntil, and every one of them is scheduled BEFORE planShellCapture
153
153
  * runs (the response, and its onResponse callbacks, are committed first). Draining
154
154
  * them turns the capture's cache reads from a RACE into an ORDERING EDGE: the
155
155
  * capture deterministically observes the foreground's cache generation, replays it
@@ -226,7 +226,7 @@ async function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
226
226
  * Module-level in-flight key map: the stampede guard for background captures, and
227
227
  * its single owner. One capture runs per key per isolate; concurrent MISS/stale
228
228
  * requests for the same key coalesce onto the first (the rest see the key present
229
- * in scheduleShellCapture and skip). Added when a capture is scheduled and cleared
229
+ * in planShellCapture and skip). Added when a capture is scheduled and cleared
230
230
  * in the task's settle paths via a token-guarded release, so a later request can
231
231
  * recapture when TTL rolls. Living here (not split across the middleware) keeps
232
232
  * the add/clear lifecycle in one layer.
@@ -234,7 +234,7 @@ async function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
234
234
  * The value is the owning task's token ({@link CaptureGuardToken}) rather than
235
235
  * a bare set entry: workerd can kill a capture's waitUntil context before its
236
236
  * finally runs (or the task can wedge past every deadline), stranding the key.
237
- * scheduleShellCapture treats an entry older than
237
+ * planShellCapture treats an entry older than
238
238
  * SHELL_CAPTURE_TASK_HARD_CAP_MS as abandoned and reclaims it; the token guard
239
239
  * keeps a late-settling stale task from releasing its replacement's entry.
240
240
  */
@@ -474,14 +474,14 @@ function refuseOnCaptureGuard(
474
474
  );
475
475
  return true;
476
476
  }
477
- const trip = derivedCtx._shellCaptureGuardTripped;
477
+ const trip = derivedCtx._shellCaptureState?.guardTripped;
478
478
  if (!trip) return false;
479
479
  // Name the recorded source instead of hardcoding a lane: a trip can come
480
480
  // from a bake-lane SEGMENT loader or from handler/render code, including a
481
481
  // loader a handler awaits (issue #672). The guard recorded the read and its
482
482
  // fix (server/context.ts guardIdentityRead): cookies()/headers(), a
483
483
  // { cache: false } variable, and the theme reads (#971).
484
- const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
484
+ const loaderId = derivedCtx._shellCaptureState?.guardTrippedLoaderId;
485
485
  const origin = loaderId
486
486
  ? `the loader "${loaderId}"`
487
487
  : "handler/render code (no loader body was executing)";
@@ -625,7 +625,7 @@ export interface ShellCaptureDebugEvent {
625
625
  * - skip-stored: a capture for the key stored after the scheduling request
626
626
  * read the store and missed (ShellCaptureDescriptor.storedSeqAtRead), so
627
627
  * the shell that request missed exists now
628
- * - skip-in-flight: scheduleShellCapture found a capture already running for
628
+ * - skip-in-flight: planShellCapture found a capture already running for
629
629
  * the key (stampede guard) and scheduled nothing
630
630
  * - skip-backoff: the key is inside its refused-capture backoff window and
631
631
  * the capture was not attempted
@@ -998,7 +998,7 @@ export function gateFlightForCapture(
998
998
  * The background shell-capture descriptor: everything the capture task needs to
999
999
  * store the shell. Built by the integrated PPR serve path (rsc-rendering.ts) from
1000
1000
  * the route's `ppr` path option (`PartialPrerenderProps`) and the app-level cache
1001
- * store, and passed to scheduleShellCapture directly — it is NOT threaded through
1001
+ * store, and passed to planShellCapture in its input — it is NOT threaded through
1002
1002
  * the request context. `tags` carries the route's OPERATIONAL `ppr.tags`; the
1003
1003
  * capture UNIONS them with the shell's own auto-collected (non-loader) request
1004
1004
  * tags from its derived render (the collected set stays authoritative). That
@@ -1066,7 +1066,7 @@ export interface ShellCaptureDescriptor {
1066
1066
  /**
1067
1067
  * Set for a shell keyed by the visitor's URL (`rewrite(...).shell("visitor")`,
1068
1068
  * visitorShellKey): the capture renders the visitor's location
1069
- * (`RequestContext._shellCaptureForVisitor`) and stores it as
1069
+ * (`ShellCaptureState.forVisitor`) and stores it as
1070
1070
  * ShellCacheEntry.pathname.
1071
1071
  */
1072
1072
  pathname?: string;
@@ -1081,7 +1081,7 @@ export interface ShellCaptureDescriptor {
1081
1081
  /**
1082
1082
  * A `router.prerender()` warm's capture (rsc-rendering.ts shellServePlan):
1083
1083
  * one explicit call that must store, so the guards that shed a herd of
1084
- * visitors do not apply to it. scheduleShellCapture skips the in-flight
1084
+ * visitors do not apply to it. planShellCapture skips the in-flight
1085
1085
  * dedup and the refused-capture backoff, and attemptCapture waits longer on
1086
1086
  * the write barrier. Every correctness guard, the inert-store skip and the
1087
1087
  * queue limits still decide.
@@ -1108,6 +1108,25 @@ function noteCaptureStored(key: string): void {
1108
1108
  storedCaptureSeqs.set(key, ++storedCaptureSeq);
1109
1109
  }
1110
1110
 
1111
+ /** One shell capture: the request it re-renders and what it stores. */
1112
+ export interface ShellCaptureInput {
1113
+ ctx: HandlerContext<any>;
1114
+ request: Request;
1115
+ env: any;
1116
+ url: URL;
1117
+ reqCtx: RequestContext<any>;
1118
+ /**
1119
+ * The SSR module for the capture's own request and URL, called once the
1120
+ * capture leaves the queue. A document passes the module it already
1121
+ * resolved, returned as is, so the capture awaits only a pending one.
1122
+ */
1123
+ ssr: (
1124
+ request: Request,
1125
+ url: URL,
1126
+ ) => SSRModule | null | Promise<SSRModule | null>;
1127
+ descriptor: ShellCaptureDescriptor;
1128
+ }
1129
+
1111
1130
  /**
1112
1131
  * Schedule the background shell capture for a served document. Stampede-guarded:
1113
1132
  * one capture per key per isolate. Runs via runBackground (waitUntil on workerd,
@@ -1115,21 +1134,10 @@ function noteCaptureStored(key: string): void {
1115
1134
  * error is routed through reportCacheError — capture is best-effort; a failure just
1116
1135
  * means the next request recaptures.
1117
1136
  *
1118
- * Eligibility (nonce/partial/status/strategy) is decided by the caller. An SSR
1119
- * module loader may be passed for cold partial requests; it runs only after the
1120
- * capture enters the guarded background queue, never on response latency.
1137
+ * Eligibility (nonce/partial/status/strategy) is decided by the caller.
1121
1138
  */
1122
- export function scheduleShellCapture(
1123
- ctx: HandlerContext<any>,
1124
- request: Request,
1125
- env: any,
1126
- url: URL,
1127
- reqCtx: RequestContext<any>,
1128
- ssrModule:
1129
- | SSRModule
1130
- | ((request: Request, url: URL) => Promise<SSRModule | null>),
1131
- descriptor: ShellCaptureDescriptor,
1132
- ): void {
1139
+ export function planShellCapture(input: ShellCaptureInput): void {
1140
+ const { ctx, request, env, url, reqCtx, descriptor } = input;
1133
1141
  const key = descriptor.key;
1134
1142
  // A forced capture installs its own token below: a visitor's capture that
1135
1143
  // started before the warm may have read data from before the change the
@@ -1191,10 +1199,9 @@ export function scheduleShellCapture(
1191
1199
  const captureRequest = descriptor.navigationOnly
1192
1200
  ? createNavigationCaptureRequest(request, captureUrl)
1193
1201
  : request;
1202
+ const ssrModule = input.ssr(captureRequest, captureUrl);
1194
1203
  const resolvedSsrModule =
1195
- typeof ssrModule === "function"
1196
- ? await ssrModule(captureRequest, captureUrl)
1197
- : ssrModule;
1204
+ ssrModule instanceof Promise ? await ssrModule : ssrModule;
1198
1205
  if (
1199
1206
  !resolvedSsrModule ||
1200
1207
  !resolvedSsrModule.resumeShellHTML ||
@@ -1273,69 +1280,73 @@ export function scheduleShellCapture(
1273
1280
  // (observed in production: ~24s of zero I/O before capture start).
1274
1281
  // observePhase reads tracing off the ALS context captured when runBackground
1275
1282
  // registered the task — the foreground request context, tracing intact.
1283
+ // onError marks every error of the capture `background`, the task's own
1284
+ // failure (captureTask's catch) included.
1276
1285
  const serializedTask = async () => {
1277
- await observePhase(PHASES.background("shell-capture"), async (span) => {
1278
- span.setAttribute("rango.shell_key", key);
1279
- // A production page can enqueue several navigation-only captures through
1280
- // viewport prefetching. Let a later document MISS overtake that queued
1281
- // speculative work so it can become a shell HIT before the 15s queue
1282
- // budget expires. Never preempts the active capture. Priority and the
1283
- // backlog ahead at enqueue ride the span (and the skip event below), so
1284
- // a skip-queue-timeout diagnoses itself instead of needing a trace dive.
1285
- const queuePriority = descriptor.navigationOnly
1286
- ? ("navigation" as const)
1287
- : ("document" as const);
1288
- const depths = captureQueueDepths();
1289
- const queueAhead =
1290
- (depths.running ? 1 : 0) +
1291
- depths.document +
1292
- (queuePriority === "navigation" ? depths.navigation : 0);
1293
- span.setAttribute("rango.background.queue_priority", queuePriority);
1294
- span.setAttribute("rango.background.queue_ahead", queueAhead);
1295
- const queueStart = performance.now();
1296
- try {
1297
- await enqueueSerializedCapture(
1298
- () => {
1286
+ await runAsBackgroundWork(reqCtx, () =>
1287
+ observePhase(PHASES.background("shell-capture"), async (span) => {
1288
+ span.setAttribute("rango.shell_key", key);
1289
+ // A production page can enqueue several navigation-only captures through
1290
+ // viewport prefetching. Let a later document MISS overtake that queued
1291
+ // speculative work so it can become a shell HIT before the 15s queue
1292
+ // budget expires. Never preempts the active capture. Priority and the
1293
+ // backlog ahead at enqueue ride the span (and the skip event below), so
1294
+ // a skip-queue-timeout diagnoses itself instead of needing a trace dive.
1295
+ const queuePriority = descriptor.navigationOnly
1296
+ ? ("navigation" as const)
1297
+ : ("document" as const);
1298
+ const depths = captureQueueDepths();
1299
+ const queueAhead =
1300
+ (depths.running ? 1 : 0) +
1301
+ depths.document +
1302
+ (queuePriority === "navigation" ? depths.navigation : 0);
1303
+ span.setAttribute("rango.background.queue_priority", queuePriority);
1304
+ span.setAttribute("rango.background.queue_ahead", queueAhead);
1305
+ const queueStart = performance.now();
1306
+ try {
1307
+ await enqueueSerializedCapture(
1308
+ () => {
1309
+ span.setAttribute(
1310
+ "rango.background.queue_wait_ms",
1311
+ Math.round(performance.now() - queueStart),
1312
+ );
1313
+ return captureTask(span);
1314
+ },
1315
+ { priority: queuePriority },
1316
+ );
1317
+ } catch (error) {
1318
+ if (error instanceof CaptureQueueFullError) {
1319
+ releaseCaptureGuard(key, guardToken);
1320
+ span.setAttribute("rango.background.outcome", "skip-capacity");
1321
+ publishCaptureDebugEvent(descriptor, {
1322
+ key,
1323
+ outcome: "skip-capacity",
1324
+ });
1325
+ return;
1326
+ }
1327
+ if (error instanceof CaptureQueueWaitTimeoutError) {
1328
+ // Dropped unrun after waiting past the queue budget: no backoff (the
1329
+ // route is not doomed, the isolate was busy) — a later request
1330
+ // re-probes the key.
1331
+ releaseCaptureGuard(key, guardToken);
1332
+ span.setAttribute("rango.background.outcome", "skip-queue-timeout");
1299
1333
  span.setAttribute(
1300
1334
  "rango.background.queue_wait_ms",
1301
- Math.round(performance.now() - queueStart),
1335
+ Math.round(error.waitedMs),
1302
1336
  );
1303
- return captureTask(span);
1304
- },
1305
- { priority: queuePriority },
1306
- );
1307
- } catch (error) {
1308
- if (error instanceof CaptureQueueFullError) {
1309
- releaseCaptureGuard(key, guardToken);
1310
- span.setAttribute("rango.background.outcome", "skip-capacity");
1311
- publishCaptureDebugEvent(descriptor, {
1312
- key,
1313
- outcome: "skip-capacity",
1314
- });
1315
- return;
1316
- }
1317
- if (error instanceof CaptureQueueWaitTimeoutError) {
1318
- // Dropped unrun after waiting past the queue budget: no backoff (the
1319
- // route is not doomed, the isolate was busy) — a later request
1320
- // re-probes the key.
1321
- releaseCaptureGuard(key, guardToken);
1322
- span.setAttribute("rango.background.outcome", "skip-queue-timeout");
1323
- span.setAttribute(
1324
- "rango.background.queue_wait_ms",
1325
- Math.round(error.waitedMs),
1326
- );
1327
- publishCaptureDebugEvent(descriptor, {
1328
- key,
1329
- outcome: "skip-queue-timeout",
1330
- queueWaitMs: Math.round(error.waitedMs),
1331
- queuePriority,
1332
- queueAhead,
1333
- });
1334
- return;
1337
+ publishCaptureDebugEvent(descriptor, {
1338
+ key,
1339
+ outcome: "skip-queue-timeout",
1340
+ queueWaitMs: Math.round(error.waitedMs),
1341
+ queuePriority,
1342
+ queueAhead,
1343
+ });
1344
+ return;
1345
+ }
1346
+ throw error;
1335
1347
  }
1336
- throw error;
1337
- }
1338
- });
1348
+ }),
1349
+ );
1339
1350
  };
1340
1351
  // The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
1341
1352
  // capture drains that list before rendering (the write-barrier ordering edge),
@@ -1350,7 +1361,7 @@ export function scheduleShellCapture(
1350
1361
  /**
1351
1362
  * The ttl/swr a shell is stored with: the route's ppr policy, capped by the
1352
1363
  * route cache() records the capture replayed or wrote
1353
- * (RequestContext._routeRecordWindow). A HIT replays the capture's handler
1364
+ * (ShellCaptureState.routeRecordWindow). A HIT replays the capture's handler
1354
1365
  * output, while a client navigation reads the route's cache() tier first, so
1355
1366
  * a shell that outlived its record would show a document request other
1356
1367
  * handler output than a navigation of the same URL. Freshness and total
@@ -1518,9 +1529,9 @@ type CaptureAttemptStats = Pick<
1518
1529
  * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
1519
1530
  * that did not run out of ppr.captureTimeout (CaptureAttemptStats.noShellCause),
1520
1531
  * and on `expired`; a genuine render error is NOT retried — it propagates to
1521
- * scheduleShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
1532
+ * planShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
1522
1533
  *
1523
- * `request` and `url` are the capture's own identity (scheduleShellCapture
1534
+ * `request` and `url` are the capture's own identity (planShellCapture
1524
1535
  * strips a navigation-only capture's transport parameters). `retryDelayMs` is
1525
1536
  * a parameter (defaulting to the module const) so unit tests can drive the
1526
1537
  * retry without a real 400ms wall-clock wait.
@@ -1541,7 +1552,7 @@ async function runShellCapture(
1541
1552
  // attemptCapture/captureAndStoreShell collecting the observability fields
1542
1553
  // (barrier wait, write-settle wait, prelude/snapshot bytes), and the event
1543
1554
  // folds them with the outcome. A genuine render error skips the attempt
1544
- // event — scheduleShellCapture's catch publishes the terminal `error` event.
1555
+ // event — planShellCapture's catch publishes the terminal `error` event.
1545
1556
  const timedAttempt = async (
1546
1557
  attempt: number,
1547
1558
  ): Promise<{
@@ -1626,7 +1637,7 @@ async function runShellCapture(
1626
1637
  // lane rule: see resolveLoaderData, loader-cache.ts) is the likely cause —
1627
1638
  // warn once per key. Ordering matters: because the retry
1628
1639
  // absorbs cold-start, cold-start routes almost never reach this warning. The
1629
- // caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
1640
+ // caller (planShellCapture) reads this `no-shell` return to back the key off.
1630
1641
  warnNullCaptureOnce(key, true, second.stats);
1631
1642
  return "no-shell";
1632
1643
  }
@@ -1673,7 +1684,8 @@ export type CaptureRecordOutcome =
1673
1684
  * a HIT restores, and its handle encode has its own 5s timeout that must
1674
1685
  * never be the one that fires); then the capture's onResponse callbacks fire
1675
1686
  * with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
1676
- * scope's own write), and the deferred writes settle.
1687
+ * scope's own write), and the deferred writes settle. A match holding an
1688
+ * error or notFound boundary throws before the callbacks fire.
1677
1689
  */
1678
1690
  export async function settleCaptureRecord(
1679
1691
  match: MatchResult,
@@ -1690,7 +1702,7 @@ export async function settleCaptureRecord(
1690
1702
  // (ctx.use(Handle).defer()), so a defer whose resolver depends on a masked
1691
1703
  // loader never settles and the wait below runs into the deadline instead.
1692
1704
  freshHandleStore.seal();
1693
- const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
1705
+ const loaderRecords = derivedCtx._shellCaptureState?.loaderRecords;
1694
1706
  const settled = await raceDeadline(
1695
1707
  Promise.all([
1696
1708
  freshHandleStore
@@ -1741,6 +1753,32 @@ export async function settleCaptureRecord(
1741
1753
  return { kind: "refused", refusal: "loader" };
1742
1754
  }
1743
1755
 
1756
+ // A boundary the match rendered in place of a handler (route, layout or
1757
+ // slot) that threw, or threw notFound(), on this pass. A MISS whose own
1758
+ // render failed answered 500/404 and scheduled no capture, so on a first
1759
+ // capture the failure is this pass's own (a flaky upstream); a recapture
1760
+ // with no visitor render behind it (stale HIT, warm, stale build shell)
1761
+ // can only have failed. The pass's _setStatus is the foreground's and the
1762
+ // callbacks below get a synthetic 200, so nothing else refuses it. A stale
1763
+ // shell whose recapture fails keeps serving, as a failed cache() refresh's
1764
+ // record does: until ttl + swr ends (a build-time shell, until a recapture
1765
+ // stores or a tag evicts it). Checked after the settle so a guard trip in
1766
+ // a handler or a loader names the read, and before any write starts.
1767
+ // Thrown like captureAndStoreShell's render errors: no retry,
1768
+ // reportCacheError and backoff in scheduleShellCapture.
1769
+ const boundary = match.segments.find(
1770
+ (s) => s.type === "error" || s.type === "notFound",
1771
+ );
1772
+ if (boundary) {
1773
+ if (refuseOnCaptureGuard(capture.key, derivedCtx))
1774
+ return { kind: "refused", refusal: guardRefusal(derivedCtx) };
1775
+ throw new Error(
1776
+ `[rango] Shell capture for "${capture.key}" rendered the ${boundary.type} ` +
1777
+ `boundary for segment "${boundary.id}"; a failed render is not stored.`,
1778
+ { cause: boundary.error ?? boundary.notFoundInfo },
1779
+ );
1780
+ }
1781
+
1744
1782
  const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
1745
1783
  if (callbacks.length > 0) {
1746
1784
  const synthetic = new Response(null, { status: 200 });
@@ -1770,7 +1808,7 @@ export async function settleCaptureRecord(
1770
1808
  !Array.isArray(record.segments) ||
1771
1809
  record.segments.length === 0
1772
1810
  ) {
1773
- // Post-match serve-source truth (cache-lookup.ts tryPrerenderLookup).
1811
+ // Post-match serve-source truth (cache-lookup.ts decodeStoredEntry).
1774
1812
  if (derivedCtx._pprReplayPostMatchReason === "prerender-store") {
1775
1813
  return { kind: "prerender", match };
1776
1814
  }
@@ -1838,25 +1876,10 @@ function recordSegmentsHaveHandles(
1838
1876
  /**
1839
1877
  * One capture attempt in a DERIVED request context.
1840
1878
  *
1841
- * The derived context is `Object.create(reqCtx)` so it inherits the foreground's
1842
- * post-middleware state (variables, cache store, env/request/url, waitUntil) while
1843
- * overriding the render-scoped accumulators as own properties:
1844
- * - _handleStore: a fresh store. The foreground store is already drained to
1845
- * completion (its stream() flipped `completed` on settle) and would throw
1846
- * LateHandlePushError on any re-push. Every downstream reader resolves the
1847
- * store off the ambient context (setupLoaderAccess captures
1848
- * _getRequestContext()._handleStore; trackHandler reads it), so the fresh
1849
- * store on the derived context is what the capture match() writes handles to.
1850
- * - _requestTags: a fresh Set. The capture collects its OWN shell tags here —
1851
- * non-loader tags only, since loaders are masked — which is exactly the tag
1852
- * set a shell entry should be invalidatable by (loader tags belong to holes).
1853
- * - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
1854
- * - _dynamic / dynamic(): an own latch the capture reads.
1855
- * - _metricsStore: undefined so the capture never appends to the foreground's
1856
- * (already-finalized) metrics.
1857
- * - _renderBarrier family: an own barrier wired to the fresh handle store
1858
- * (wireRenderBarrier), plus _treeHasStreaming/deadlock-guard resets — the
1859
- * capture's rendered() lifecycle is its own, not the foreground's.
1879
+ * The derived context (deriveRenderPass, server/render-pass.ts) inherits the
1880
+ * foreground's post-middleware state (variables, cache store, env/request/url,
1881
+ * waitUntil) and owns the render-scoped accumulators, its render barrier
1882
+ * included: RENDER_PASS_FIELDS["shell-capture"] lists each and why.
1860
1883
  *
1861
1884
  * The capture is MIXED-CHAIN: its match() behaves like a normal render with
1862
1885
  * respect to the segment cache — cache()'d segments replay from ring 3, UNCACHED
@@ -1913,7 +1936,7 @@ async function attemptCapture(
1913
1936
  );
1914
1937
  const { derivedCtx, freshHandleStore } = derivation;
1915
1938
  if (descriptor.pathname !== undefined) {
1916
- derivedCtx._shellCaptureForVisitor = true;
1939
+ derivedCtx._shellCaptureState!.forVisitor = true;
1917
1940
  }
1918
1941
  // The capture generation starts before matching or any snapshot read
1919
1942
  // (ShellCacheEntry.createdAt): a tag invalidated after it wins.
@@ -2014,6 +2037,115 @@ async function attemptCapture(
2014
2037
  });
2015
2038
  }
2016
2039
 
2040
+ /**
2041
+ * @internal The freshness window of a route cache() record, as ms
2042
+ * timestamps: fresh until `freshUntil` (its `expiresAt`), stale until
2043
+ * `staleUntil` (plus its scope's swr). `ttl`, `swr` and `written` describe
2044
+ * the record that sets `staleUntil`: its cache() config, and whether this
2045
+ * capture wrote it (cacheRoute) rather than read it.
2046
+ */
2047
+ export interface RouteRecordWindow {
2048
+ freshUntil: number;
2049
+ staleUntil: number;
2050
+ ttl: number;
2051
+ swr: number;
2052
+ written: boolean;
2053
+ }
2054
+
2055
+ /**
2056
+ * @internal What a shell capture's render records about itself, on its
2057
+ * derived request context as `_shellCaptureState`.
2058
+ * RENDER_PASS_FIELDS["shell-capture"] (server/render-pass.ts) creates it with
2059
+ * every field present, so it keeps one shape while the capture writes it. A
2060
+ * background re-render the capture starts has none: it writes a route
2061
+ * record, under cache() rules (RENDER_PASS_FIELDS["background-revalidation"]).
2062
+ */
2063
+ export interface ShellCaptureState {
2064
+ /**
2065
+ * @internal PPR shell-capture ACTIVE marker. True ONLY inside the background
2066
+ * capture task's derived request context (built by shell-capture.ts). This is
2067
+ * the switch every capture-specific behavior reads: loader masking
2068
+ * (loader-mask.ts isShellCaptureActive / resolveOwnerLoaders) and the
2069
+ * identity-read capture guard (server/context.ts guardIdentityRead). The
2070
+ * foreground render never sets it, so the
2071
+ * served response is byte-identical to axis 1. The capture descriptor itself
2072
+ * (key/ttl/swr/tags/store) is NOT threaded through the request context — the
2073
+ * integrated PPR serve path (rsc/shell-serve.ts + rsc-rendering.ts) builds it
2074
+ * locally and passes it to planShellCapture in its input.
2075
+ */
2076
+ readonly run: true;
2077
+
2078
+ /**
2079
+ * @internal Set beside `run` when the shell is keyed by the
2080
+ * visitor's URL (`rewrite(...).shell("visitor")`): the capture renders the
2081
+ * visitor's location and `ctx.rewrittenFrom`; the theme stays the default.
2082
+ */
2083
+ forVisitor: boolean | undefined;
2084
+
2085
+ /**
2086
+ * @internal Bake-lane loader containers collected DURING a shell capture:
2087
+ * segment-key -> the loader's (pre-wrap) result promise. Populated by
2088
+ * resolveLoaderData for bake-lane loaders (lane rule: see its JSDoc in
2089
+ * loader-cache.ts). Drained by captureAndStoreShell after the shell
2090
+ * quiesces: settled containers are promise-elided, Flight-serialized, and
2091
+ * pinned into the snapshot's loader family; a REJECTED container refuses
2092
+ * the capture (error UI must never bake into the shared shell).
2093
+ */
2094
+ loaderRecords: Map<string, Promise<unknown>>;
2095
+
2096
+ /**
2097
+ * @internal Set when a loader pushed a
2098
+ * value the record cannot keep (masked nested promises, a pushed promise
2099
+ * that settled to one, a live-lane loader's push, or a push outside a
2100
+ * loader body): the loader records then carry `runs: 1`, so a HIT still
2101
+ * runs their bodies for those pushes.
2102
+ */
2103
+ unrecordedLoaderPush: true | undefined;
2104
+
2105
+ /**
2106
+ * @internal The lane of each loader the capture's matched route registers
2107
+ * (loader-cache.ts routeLoaderLanes), set by createMatchContextForFull
2108
+ * before any loader runs. The capture's push funnel credits a push made
2109
+ * under a live-lane loader's body, at any depth, to that loader
2110
+ * (shell-capture.ts deriveShellCaptureContext).
2111
+ */
2112
+ loaderLanes: ReadonlyMap<string, "live" | "bake"> | undefined;
2113
+
2114
+ /**
2115
+ * @internal Set by the capture guard (server/context.ts
2116
+ * guardIdentityRead) when it throws DURING a capture render: the read
2117
+ * ("cookies()", "headers()", `ctx.get() for a non-cacheable variable`,
2118
+ * "ctx.theme") and the fix the
2119
+ * refusal warning gives for it. Load-bearing for the
2120
+ * bake lane: a guard throw inside an executing loader is swallowed by
2121
+ * wrapLoaderPromise into per-loader error UI, which would otherwise bake
2122
+ * silently into the shared shell — the capture checks this flag after the
2123
+ * render and refuses instead. Deterministic, so the capture does not retry.
2124
+ */
2125
+ guardTripped: { surface: string; fix: string } | undefined;
2126
+
2127
+ /**
2128
+ * @internal The loader $$id whose BODY was executing when the capture guard
2129
+ * tripped (read off the loader-body ALS scope at trip time), or undefined
2130
+ * when the read came from handler/render code. Only used to make the
2131
+ * once-per-key refusal warning name the real source — the old text
2132
+ * hardcoded "a bake-lane loader", which misattributed handler-land reads
2133
+ * and sent users debugging the wrong lane (issue #672, secondary).
2134
+ */
2135
+ guardTrippedLoaderId: string | undefined;
2136
+
2137
+ /**
2138
+ * @internal The freshness window of the route cache() records this
2139
+ * capture read or wrote (cache-scope.ts noteRouteRecordWindow), as ms
2140
+ * timestamps: `freshUntil` is the record's `expiresAt`, `staleUntil` adds
2141
+ * its scope's swr. The narrowest record wins. A shell capture caps its
2142
+ * entry's ttl and ttl+swr to it (shell-capture.ts capShellWindow), so a
2143
+ * shell is never fresher, or served longer, than the route cache() entry
2144
+ * it replayed or wrote.
2145
+ */
2146
+ routeRecordWindow: RouteRecordWindow | undefined;
2147
+ }
2148
+
2017
2149
  /**
2018
2150
  * The derived capture context and its fresh (mask-funneled) handle store,
2019
2151
  * shared by BOTH shell producers: the runtime background capture
@@ -2123,7 +2255,7 @@ export function deriveShellCaptureContext(
2123
2255
  // A replay of a live-lane loader's push (a "use cache" or loader cache()
2124
2256
  // hit a recapture makes inside a bake-lane body) is that hole's too.
2125
2257
  const liveLaneOwner = (): string | undefined => {
2126
- const lanes = derivedCtx._shellCaptureLoaderLanes;
2258
+ const lanes = captureState.loaderLanes;
2127
2259
  if (!lanes) return undefined;
2128
2260
  return (
2129
2261
  findEnclosingLoaderBody(
@@ -2190,7 +2322,7 @@ export function deriveShellCaptureContext(
2190
2322
  const judged = mask(settled);
2191
2323
  if (maskedNestedThenable) {
2192
2324
  freshHandleStore.unrecordPush(handleName, segmentId, slot);
2193
- derivedCtx._shellCaptureUnrecordedLoaderPush = true;
2325
+ captureState.unrecordedLoaderPush = true;
2194
2326
  }
2195
2327
  return judged;
2196
2328
  });
@@ -2220,7 +2352,7 @@ export function deriveShellCaptureContext(
2220
2352
  // A push the record cannot keep reaches a HIT only from a live run of
2221
2353
  // its loader, so the loader records ask for one (runs: 1).
2222
2354
  const loaderPush = owner === undefined;
2223
- if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
2355
+ if (loaderPush) captureState.unrecordedLoaderPush = true;
2224
2356
  rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
2225
2357
  };
2226
2358
 
@@ -2229,111 +2361,25 @@ export function deriveShellCaptureContext(
2229
2361
  // request before the derivation, or the capture would start its own and
2230
2362
  // run key() under the capture guard.
2231
2363
  reqCtx._resolvedCacheKeys ??= new Map();
2232
- const derivedCtx: RequestContext = Object.assign(Object.create(reqCtx), {
2233
- _handleStore: freshHandleStore,
2234
- // The capture notes the route records it reads or writes itself; the
2235
- // foreground's (a partial request's `partial:` record, say) are not the
2236
- // ones this shell is captured from.
2237
- _routeRecordWindow: undefined,
2364
+ const recordingStore = reqCtx._cacheStore
2365
+ ? new RecordingShellStore(reqCtx._cacheStore)
2366
+ : undefined;
2367
+ // What the capture owns, and why: RENDER_PASS_FIELDS["shell-capture"].
2368
+ const derivedCtx: RequestContext = deriveRenderPass(reqCtx, "shell-capture", {
2369
+ handleStore: freshHandleStore,
2370
+ identity,
2371
+ recording: recordingStore && {
2372
+ store: recordingStore,
2373
+ implicitCache: {
2374
+ ttl: descriptor.ttl,
2375
+ swr: descriptor.swr,
2376
+ store: new SnapshotOnlySegmentStore(recordingStore),
2377
+ keyPrefix: "doc",
2378
+ },
2379
+ },
2238
2380
  });
2239
- if (identity) {
2240
- derivedCtx.request = identity.request;
2241
- derivedCtx.url = identity.url;
2242
- derivedCtx.originalUrl = new URL(identity.url);
2243
- derivedCtx.pathname = identity.url.pathname;
2244
- derivedCtx.searchParams = identity.url.searchParams;
2245
- }
2246
- // Own render barrier, closure-bound to the derived ctx and the fresh store
2247
- // (issue #684, plan 009). Without this every _renderBarrier* read fell
2248
- // through the prototype to the foreground's ALREADY-RESOLVED barrier: a
2249
- // bake-lane loader's `await ctx.rendered()` resolved instantly and
2250
- // ctx.use(handle) read the FOREGROUND handle snapshot — foreground
2251
- // per-request handle data could bake into the shared shell. wireRenderBarrier
2252
- // also resets _treeHasStreaming (recomputed for the capture's tree) and the
2253
- // deadlock-guard fields as own properties.
2254
- wireRenderBarrier(derivedCtx, freshHandleStore);
2255
- derivedCtx._requestTags = new Set<string>();
2256
- // Own explicit-store registry: cache-store resolutions during the capture
2257
- // (the implicit scope's SnapshotOnlySegmentStore, any per-capture explicit
2258
- // store instance) must NOT register into the handler-lifetime
2259
- // _explicitTaggedStores set — a capture-ephemeral store pinned there would
2260
- // trip the partial-tag-store warning on every later updateTag() and retain
2261
- // the whole capture snapshot in memory. Capture registrations die with this
2262
- // context; module-singleton stores stay registered by normal renders.
2263
- derivedCtx._explicitTaggedStores = new Set();
2264
- // Own list: the capture's render errors refuse the capture only, and the
2265
- // foreground's never reach it.
2266
- derivedCtx._renderErrors = [];
2267
- derivedCtx._recoveredHandlerErrors = undefined;
2268
- derivedCtx._shellCaptureRun = true;
2269
- // Own dynamic() latch. The inherited method writes the FOREGROUND context
2270
- // (its closure), which already served, and nothing read it for a runtime
2271
- // capture: handler code that opts out after an await (a handler promise the
2272
- // capture now waits for) must refuse THIS capture instead
2273
- // (settleCaptureRecord and captureAndStoreShell read _dynamic). The ppr
2274
- // header latch is left alone: a capture's response is never sent.
2275
- derivedCtx._dynamic = false;
2276
- derivedCtx.dynamic = (): void => {
2277
- derivedCtx._dynamic = true;
2278
- };
2279
- // Own-property reset: an inherited flag would make serializeSegments store
2280
- // a double-encoded fragment. No current arming site reaches a capture
2281
- // render (kept as defense-in-depth for a future one).
2282
- derivedCtx._shellFragmentPayload = false;
2283
- derivedCtx._metricsStore = undefined;
2284
- // Spans, like perf metrics above, are a FOREGROUND surface: the capture
2285
- // re-render must not emit a second rango.render/loader/ssr set after the
2286
- // foreground rango.request span ended (orphan spans in the trace).
2287
- // _tracing is otherwise inherited through Object.create(reqCtx).
2288
- derivedCtx._tracing = undefined;
2289
- // Bake-lane (`ssr: false`) loader containers, which execute during capture
2290
- // (docs/design/loader-container-bake.md). resolveLoaderData registers each
2291
- // container promise here; settleCaptureRecord waits for them, and the drain
2292
- // in captureAndStoreShell elides + pins them into the snapshot's loader
2293
- // family.
2294
- derivedCtx._shellCaptureLoaderRecords = new Map();
2295
- // Own onResponse list so the capture's match-middleware callbacks (the doc
2296
- // record registers here) are ISOLATED from the foreground's shared array AND
2297
- // can be fired by settleCaptureRecord. The segment write is gated behind
2298
- // onResponse, which the capture never triggers (it builds no Response):
2299
- // without the synthetic fire, no doc record is written.
2300
- derivedCtx._onResponseCallbacks = [];
2301
-
2302
- // Capture data snapshot: route the capture's cache-store calls through a
2303
- // recording wrapper on the DERIVED context's store (own property, so the
2304
- // shared reqCtx._cacheStore is untouched — the snapshot is per-capture). It
2305
- // records the doc segment record, which rides inside the ShellCacheEntry
2306
- // with the bake-lane loader pins; no cache read is recorded. See
2307
- // cache/shell-snapshot.ts and the design doc.
2308
- //
2309
- // Cache writes are deferred (waitUntil): the doc record's write would
2310
- // otherwise land after the match returns. Override the derived context's
2311
- // waitUntil to COLLECT those write promises (still forwarding to the parent
2312
- // so the write persists and the worker stays alive), then
2313
- // settleCaptureRecord and captureAndStoreShell await them before reading
2314
- // the record and draining.
2315
- if (reqCtx._cacheStore) {
2316
- const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
2317
- derivedCtx._cacheStore = recordingStore;
2318
- derivedCtx.waitUntil = (fn: () => Promise<void>): void => {
2319
- const p = Promise.resolve().then(fn);
2320
- recordingStore.trackWrite(p);
2321
- reqCtx.waitUntil(() => p);
2322
- };
2323
- // The doc record (capture side): the implicit doc-cache scope makes the
2324
- // capture's match write ALL matched non-loader segments as one doc-keyed
2325
- // segment record — into the snapshot only (SnapshotOnlySegmentStore), so
2326
- // the record dies with the shell entry and the next capture's lookup
2327
- // still misses (handlers re-run on recapture). A route deriving its own
2328
- // cache() scope keeps it, and recordShellCaptureDocRecord (cache-store.ts)
2329
- // writes the same record for it.
2330
- derivedCtx._shellImplicitCache = {
2331
- ttl: descriptor.ttl,
2332
- swr: descriptor.swr,
2333
- store: new SnapshotOnlySegmentStore(recordingStore),
2334
- keyPrefix: "doc",
2335
- };
2336
- }
2381
+ // The row creates it; the push funnel above writes it.
2382
+ const captureState = derivedCtx._shellCaptureState!;
2337
2383
 
2338
2384
  return {
2339
2385
  derivedCtx,
@@ -2479,7 +2525,7 @@ async function captureAndStoreShell(
2479
2525
  // container still pending here either pinned the tree (the trivial-prelude
2480
2526
  // gate above already returned no-shell) or postponed under an ANCESTOR
2481
2527
  // boundary (it is a hole; omitting the record keeps it live).
2482
- const loaderRecords = reqCtx._shellCaptureLoaderRecords;
2528
+ const loaderRecords = reqCtx._shellCaptureState?.loaderRecords;
2483
2529
  // Set once a bake-lane loader settles with real (non-hole) material: its data
2484
2530
  // is frozen into the shell prelude regardless of whether snapshot
2485
2531
  // serialization succeeds. Drives the opt-in debug metadata below.
@@ -2492,7 +2538,9 @@ async function captureAndStoreShell(
2492
2538
  | typeof import("../cache/segment-codec.js").serializeResult
2493
2539
  | undefined;
2494
2540
  // Capture-wide, read once: every record of this snapshot agrees.
2495
- const runs: 0 | 1 = reqCtx._shellCaptureUnrecordedLoaderPush ? 1 : 0;
2541
+ const runs: 0 | 1 = reqCtx._shellCaptureState?.unrecordedLoaderPush
2542
+ ? 1
2543
+ : 0;
2496
2544
  for (const [segmentKey, containerPromise] of loaderRecords) {
2497
2545
  const elided = await elideLoaderContainer(containerPromise);
2498
2546
  if (elided.state === "rejected") {
@@ -2583,7 +2631,7 @@ async function captureAndStoreShell(
2583
2631
  // The drain's container encode adds its Flight errors here too (#927).
2584
2632
  // After the loader drain so a rejected bake-lane loader keeps its specific
2585
2633
  // refusal. Thrown like a fatal shell error: no retry, reportCacheError and
2586
- // backoff in scheduleShellCapture.
2634
+ // backoff in planShellCapture.
2587
2635
  const renderErrors = reqCtx._renderErrors;
2588
2636
  if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
2589
2637
 
@@ -2744,7 +2792,7 @@ async function captureAndStoreShell(
2744
2792
  // Capped to the route record the capture used: the policy the
2745
2793
  // store would apply (resolveTtl/resolveSwrWindow, as putShell does),
2746
2794
  // made explicit.
2747
- const record = reqCtx._routeRecordWindow;
2795
+ const record = reqCtx._shellCaptureState?.routeRecordWindow;
2748
2796
  let window: { ttl?: number; swr?: number } = {
2749
2797
  ttl: capture.ttl,
2750
2798
  swr: capture.swr,