@rangojs/router 0.26.6 → 0.28.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 (234) 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-coordinator.d.ts +0 -4
  4. package/dist/types/browser/action-fence.d.ts +11 -3
  5. package/dist/types/browser/event-controller.d.ts +20 -1
  6. package/dist/types/browser/history-state.d.ts +7 -9
  7. package/dist/types/browser/intercept-utils.d.ts +23 -0
  8. package/dist/types/browser/navigation-store.d.ts +8 -6
  9. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  10. package/dist/types/browser/rsc-router.d.ts +1 -1
  11. package/dist/types/browser/segment-reconciler.d.ts +6 -4
  12. package/dist/types/browser/types.d.ts +2 -1
  13. package/dist/types/browser/wire-protocol.d.ts +25 -0
  14. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  15. package/dist/types/cache/cache-scope.d.ts +34 -22
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  18. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  19. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  20. package/dist/types/cache/segment-codec.d.ts +14 -0
  21. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  22. package/dist/types/cache/tag-invalidation.d.ts +10 -0
  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 +4 -6
  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 +4 -4
  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 +12 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +10 -20
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +35 -87
  38. package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
  39. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  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/revalidation.d.ts +6 -1
  44. package/dist/types/router/router-context.d.ts +9 -63
  45. package/dist/types/router/router-interfaces.d.ts +8 -14
  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 +93 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution.d.ts +1 -2
  52. package/dist/types/router/segment-wrappers.d.ts +11 -48
  53. package/dist/types/router/telemetry.d.ts +2 -0
  54. package/dist/types/router/types.d.ts +10 -0
  55. package/dist/types/rsc/full-payload.d.ts +2 -2
  56. package/dist/types/rsc/handler-context.d.ts +6 -6
  57. package/dist/types/rsc/json-route-result.d.ts +5 -9
  58. package/dist/types/rsc/request-stages.d.ts +203 -0
  59. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  60. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  61. package/dist/types/rsc/routine-plan.d.ts +1 -1
  62. package/dist/types/rsc/server-action.d.ts +2 -2
  63. package/dist/types/rsc/shell-capture-constants.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture.d.ts +132 -15
  65. package/dist/types/rsc/shell-serve.d.ts +37 -12
  66. package/dist/types/server/context.d.ts +9 -8
  67. package/dist/types/server/handle-store.d.ts +16 -0
  68. package/dist/types/server/render-pass.d.ts +72 -0
  69. package/dist/types/server/request-context.d.ts +24 -104
  70. package/dist/types/server/rewrite-trail.d.ts +2 -2
  71. package/dist/types/testing/dispatch.d.ts +35 -18
  72. package/dist/types/testing/serve-shell-request.d.ts +7 -0
  73. package/dist/types/types/cache-types.d.ts +6 -3
  74. package/dist/types/types/error-types.d.ts +9 -1
  75. package/dist/types/types/handler-context.d.ts +18 -7
  76. package/dist/types/types/segments.d.ts +1 -1
  77. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  78. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  79. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  80. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  81. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  82. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  83. package/dist/types/vite/discovery/state.d.ts +12 -0
  84. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  85. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  86. package/dist/types/vite/router-discovery.d.ts +8 -47
  87. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  88. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  89. package/dist/types/wire-params.d.ts +34 -0
  90. package/dist/vite/index.js +3809 -3710
  91. package/package.json +1 -1
  92. package/skills/cache-guide/SKILL.md +8 -5
  93. package/skills/caching/SKILL.md +37 -18
  94. package/skills/client-urls/SKILL.md +2 -1
  95. package/skills/document-cache/SKILL.md +18 -1
  96. package/skills/handler-use/SKILL.md +1 -1
  97. package/skills/intercept/SKILL.md +53 -13
  98. package/skills/layout/SKILL.md +11 -7
  99. package/skills/loader/SKILL.md +30 -13
  100. package/skills/middleware/SKILL.md +19 -1
  101. package/skills/migrate-nextjs/SKILL.md +5 -4
  102. package/skills/parallel/SKILL.md +5 -4
  103. package/skills/ppr/SKILL.md +19 -3
  104. package/skills/prerender/SKILL.md +39 -15
  105. package/skills/rango/SKILL.md +23 -11
  106. package/skills/server-actions/SKILL.md +51 -6
  107. package/skills/testing/predicates.md +1 -1
  108. package/skills/testing/response-routes.md +6 -4
  109. package/skills/testing/serve-shell-request.md +7 -7
  110. package/skills/use-cache/SKILL.md +46 -8
  111. package/skills/ux-architecture/SKILL.md +3 -2
  112. package/src/__internal.ts +7 -1
  113. package/src/browser/action-coordinator.ts +2 -13
  114. package/src/browser/action-fence.ts +11 -3
  115. package/src/browser/connection-warmup.ts +3 -1
  116. package/src/browser/event-controller.ts +42 -3
  117. package/src/browser/history-state.ts +15 -13
  118. package/src/browser/intercept-utils.ts +51 -0
  119. package/src/browser/navigation-bridge.ts +34 -30
  120. package/src/browser/navigation-client.ts +21 -33
  121. package/src/browser/navigation-store.ts +25 -27
  122. package/src/browser/navigation-transaction.ts +7 -11
  123. package/src/browser/partial-update.ts +8 -0
  124. package/src/browser/prefetch/cache.ts +2 -1
  125. package/src/browser/prefetch/fetch.ts +8 -16
  126. package/src/browser/prefetch/queue.ts +4 -4
  127. package/src/browser/react/NavigationProvider.tsx +2 -1
  128. package/src/browser/react/filter-segment-order.ts +2 -2
  129. package/src/browser/rsc-router.tsx +17 -2
  130. package/src/browser/segment-reconciler.ts +66 -53
  131. package/src/browser/server-action-bridge.ts +265 -127
  132. package/src/browser/types.ts +2 -1
  133. package/src/browser/wire-protocol.ts +41 -0
  134. package/src/cache/cache-key-utils.ts +11 -5
  135. package/src/cache/cache-runtime.ts +110 -50
  136. package/src/cache/cache-scope.ts +110 -64
  137. package/src/cache/cf/cf-cache-constants.ts +2 -2
  138. package/src/cache/cf/cf-cache-store.ts +486 -874
  139. package/src/cache/cf/cf-kv-utils.ts +1 -1
  140. package/src/cache/document-cache.ts +42 -15
  141. package/src/cache/handle-snapshot.ts +19 -7
  142. package/src/cache/segment-codec.ts +55 -0
  143. package/src/cache/shell-snapshot.ts +2 -2
  144. package/src/cache/tag-invalidation.ts +21 -2
  145. package/src/cache/taint.ts +5 -3
  146. package/src/cache/types.ts +7 -3
  147. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  148. package/src/handles/pushed-by.ts +1 -1
  149. package/src/prefetch-markers.ts +4 -7
  150. package/src/prerender/store.ts +76 -0
  151. package/src/prerender/warm-request.ts +8 -4
  152. package/src/prerender.ts +2 -2
  153. package/src/route-name.ts +8 -0
  154. package/src/router/error-handling.ts +4 -4
  155. package/src/router/instrument.ts +4 -3
  156. package/src/router/intercept-resolution.ts +77 -75
  157. package/src/router/loader-resolution.ts +5 -4
  158. package/src/router/match-api.ts +4 -5
  159. package/src/router/match-context.ts +33 -12
  160. package/src/router/match-handlers.ts +8 -20
  161. package/src/router/match-middleware/background-revalidation.ts +41 -53
  162. package/src/router/match-middleware/cache-lookup.ts +680 -720
  163. package/src/router/match-middleware/cache-store.ts +14 -12
  164. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  165. package/src/router/match-middleware/segment-resolution.ts +34 -61
  166. package/src/router/match-result.ts +1 -20
  167. package/src/router/middleware.ts +69 -11
  168. package/src/router/navigation-snapshot.ts +4 -3
  169. package/src/router/prerender-match.ts +100 -44
  170. package/src/router/preview-match.ts +6 -41
  171. package/src/router/request-classification.ts +13 -7
  172. package/src/router/revalidation.ts +33 -7
  173. package/src/router/route-snapshot.ts +2 -5
  174. package/src/router/router-context.ts +14 -117
  175. package/src/router/router-interfaces.ts +8 -14
  176. package/src/router/segment-resolution/loader-cache.ts +14 -9
  177. package/src/router/segment-resolution/loader-mask.ts +5 -5
  178. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  179. package/src/router/segment-resolution/resolution-pass.ts +137 -0
  180. package/src/router/segment-resolution/revalidation.ts +897 -776
  181. package/src/router/segment-resolution.ts +4 -13
  182. package/src/router/segment-wrappers.ts +51 -269
  183. package/src/router/telemetry.ts +5 -0
  184. package/src/router/types.ts +10 -0
  185. package/src/router.ts +12 -13
  186. package/src/rsc/full-payload.ts +6 -5
  187. package/src/rsc/handler-context.ts +8 -14
  188. package/src/rsc/handler.ts +126 -507
  189. package/src/rsc/json-route-result.ts +5 -9
  190. package/src/rsc/loader-fetch.ts +6 -5
  191. package/src/rsc/progressive-enhancement.ts +5 -0
  192. package/src/rsc/request-stages.ts +757 -0
  193. package/src/rsc/response-cache-serve.ts +10 -14
  194. package/src/rsc/response-route-handler.ts +13 -5
  195. package/src/rsc/rewrite-passes.ts +2 -1
  196. package/src/rsc/routine-plan.ts +1 -1
  197. package/src/rsc/rsc-rendering.ts +172 -349
  198. package/src/rsc/server-action.ts +87 -113
  199. package/src/rsc/shell-capture-constants.ts +3 -1
  200. package/src/rsc/shell-capture.ts +212 -169
  201. package/src/rsc/shell-serve.ts +121 -18
  202. package/src/rsc/ssr-setup.ts +5 -4
  203. package/src/segment-system.tsx +3 -3
  204. package/src/server/context.ts +22 -25
  205. package/src/server/handle-store.ts +55 -0
  206. package/src/server/render-pass.ts +291 -0
  207. package/src/server/request-context.ts +26 -115
  208. package/src/server/rewrite-trail.ts +6 -4
  209. package/src/ssr/index.tsx +1 -1
  210. package/src/ssr/ssr-root.tsx +1 -0
  211. package/src/testing/dispatch.ts +223 -710
  212. package/src/testing/e2e/page-helpers.ts +2 -2
  213. package/src/testing/serve-shell-request.ts +37 -32
  214. package/src/types/cache-types.ts +6 -3
  215. package/src/types/error-types.ts +9 -1
  216. package/src/types/handler-context.ts +21 -7
  217. package/src/types/segments.ts +1 -1
  218. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  219. package/src/vite/discovery/build-env.ts +148 -0
  220. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  221. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  222. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  223. package/src/vite/discovery/discover-routers.ts +1 -1
  224. package/src/vite/discovery/discovery-errors.ts +1 -1
  225. package/src/vite/discovery/prerender-collection.ts +24 -39
  226. package/src/vite/discovery/state.ts +15 -0
  227. package/src/vite/plugins/version-plugin.ts +1 -1
  228. package/src/vite/plugins/virtual-entries.ts +1 -1
  229. package/src/vite/router-discovery.ts +29 -2428
  230. package/src/vite/temp-rsc-server.ts +223 -0
  231. package/src/vite/utils/prerender-utils.ts +47 -0
  232. package/src/wire-params.ts +35 -0
  233. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  234. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -10,8 +10,8 @@ import {
10
10
  getRequestContext,
11
11
  setRequestContextParams,
12
12
  runWithRequestContext,
13
- wireRenderBarrier,
14
13
  } from "../server/request-context.js";
14
+ import { deriveRenderPass } from "../server/render-pass.js";
15
15
  import {
16
16
  SeededShellStore,
17
17
  ShellRecordUnavailableError,
@@ -58,11 +58,11 @@ import {
58
58
  capShellWindow,
59
59
  lastStoredCaptureSeq,
60
60
  markCaptureBackoff,
61
- scheduleShellCapture,
61
+ planShellCapture,
62
62
  resolveShellCaptureDebugSink,
63
63
  takeCaptureDebugEventForTiming,
64
64
  describeShellCaptureEvent,
65
- type ShellCaptureDescriptor,
65
+ type ShellCaptureInput,
66
66
  } from "./shell-capture.js";
67
67
  import {
68
68
  PPR_REPLAY_STATUS_HEADER,
@@ -87,8 +87,10 @@ import {
87
87
  describeShellTailTiming,
88
88
  publishShellTailTiming,
89
89
  takeShellTailTimingForServerTiming,
90
+ readShell,
90
91
  type ResolvedPprConfig,
91
92
  type ShellDocument,
93
+ type ShellRead,
92
94
  type ShellTailTiming,
93
95
  } from "./shell-serve.js";
94
96
  import {
@@ -100,6 +102,7 @@ import { contextGet } from "../context-var.js";
100
102
  import { rewriteShellAudience, rewriteTrail } from "../server/rewrite-trail.js";
101
103
  import { visitorShellKey } from "./shell-capture-constants.js";
102
104
  import { PREFETCH_HEADER } from "../prefetch-markers.js";
105
+ import { WIRE } from "../wire-params.js";
103
106
  import {
104
107
  isWarmReplace,
105
108
  noteWarmShellEvent,
@@ -130,9 +133,7 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
130
133
  import type {
131
134
  SegmentCacheStore,
132
135
  ShellCacheEntry,
133
- ShellDocumentRead,
134
136
  ShellEntryHead,
135
- ShellReadStats,
136
137
  ShellSnapshotRecord,
137
138
  } from "../cache/types.js";
138
139
  import {
@@ -202,33 +203,41 @@ function requestShellBase(
202
203
  };
203
204
  }
204
205
 
205
- function createShellCaptureDescriptor(
206
- ctx: HandlerContext<any>,
206
+ /** The shell capture of `key` this request schedules. */
207
+ function createShellCapture(
208
+ input: RequestRenderInput<any>,
207
209
  key: string,
208
210
  base: RequestShellBase,
209
- originSeed: string,
210
211
  pprConfig: ResolvedPprConfig,
211
- store: SegmentCacheStore<any>,
212
+ ssr: ShellCaptureInput["ssr"],
212
213
  navigationOnly?: true,
213
- ): ShellCaptureDescriptor {
214
+ ): ShellCaptureInput {
215
+ const { ctx, request, env, url, reqCtx } = input;
214
216
  return {
215
- key,
216
- searchSeed: base.searchSeed,
217
- pathname: base.pathname,
218
- // Derived from the same request URL as the key (the callers compute
219
- // both next to each other, so they cannot drift): seeding the origin
220
- // keeps origin-dependent static markup (Link's data-external)
221
- // identical across capture, resume, and browser hydration.
222
- originSeed,
223
- buildVersion: ctx.version,
224
- ttl: pprConfig.ttl,
225
- swr: pprConfig.swr,
226
- tags: pprConfig.tags,
227
- captureTimeout: pprConfig.captureTimeout,
228
- store,
229
- maxSnapshotBytes: pprConfig.maxSnapshotBytes,
230
- debugSink: resolveShellCaptureDebugSink(ctx.router.debugShellCapture),
231
- navigationOnly,
217
+ ctx,
218
+ request,
219
+ env,
220
+ url,
221
+ reqCtx,
222
+ ssr,
223
+ descriptor: {
224
+ key,
225
+ searchSeed: base.searchSeed,
226
+ pathname: base.pathname,
227
+ // Derived from the same request URL as the key: seeding the origin
228
+ // keeps origin-dependent static markup (Link's data-external)
229
+ // identical across capture, resume, and browser hydration.
230
+ originSeed: url.origin,
231
+ buildVersion: ctx.version,
232
+ ttl: pprConfig.ttl,
233
+ swr: pprConfig.swr,
234
+ tags: pprConfig.tags,
235
+ captureTimeout: pprConfig.captureTimeout,
236
+ store: reqCtx._cacheStore,
237
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
238
+ debugSink: resolveShellCaptureDebugSink(ctx.router.debugShellCapture),
239
+ navigationOnly,
240
+ },
232
241
  };
233
242
  }
234
243
 
@@ -343,7 +352,7 @@ interface RequestRenderInput<TEnv> {
343
352
 
344
353
  type ShellServeOutcome =
345
354
  | { kind: "serve"; response: Response }
346
- | { kind: "miss"; descriptor: ShellCaptureDescriptor; ssrModule: SSRModule }
355
+ | { kind: "miss"; capture: ShellCaptureInput }
347
356
  | { kind: "pass" };
348
357
 
349
358
  type PreparedRender =
@@ -454,18 +463,8 @@ function* requestRenderPlan<TEnv>(
454
463
  response.status === 200 &&
455
464
  (response.headers.get("content-type") ?? "").includes("text/html")
456
465
  ) {
457
- const { descriptor, ssrModule } = shell;
458
- yield* handoff("shell-capture", () =>
459
- scheduleShellCapture(
460
- input.ctx,
461
- input.request,
462
- input.env,
463
- input.url,
464
- reqCtx,
465
- ssrModule,
466
- descriptor,
467
- ),
468
- );
466
+ const { capture } = shell;
467
+ yield* handoff("shell-capture", () => planShellCapture(capture));
469
468
  }
470
469
  response.headers.set(SHELL_STATUS_HEADER, "MISS");
471
470
  }
@@ -520,8 +519,7 @@ function warnIfPprWindowReduced(
520
519
  function* shellServePlan<TEnv>(
521
520
  input: RequestRenderInput<TEnv>,
522
521
  ): RoutinePlan<ShellServeOutcome> {
523
- const { ctx, request, env, url, isPartial, handleStore, nonce, reqCtx } =
524
- input;
522
+ const { ctx, request, env, url, isPartial, nonce, reqCtx } = input;
525
523
  const manifestEntry = reqCtx._classifiedRoute?.manifestEntry;
526
524
 
527
525
  // A router.prerender() warm (prerender/warm-request.ts) reports the shell of
@@ -627,7 +625,10 @@ function* shellServePlan<TEnv>(
627
625
  const cached = warmReplace
628
626
  ? null
629
627
  : yield* step("shell-read", () =>
630
- readShellEntry(store, key, reqCtx, pprConfig.tags),
628
+ readShell(store, key, {
629
+ tagHints: pprConfig.tags,
630
+ metrics: reqCtx._metricsStore,
631
+ }),
631
632
  );
632
633
 
633
634
  // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
@@ -643,14 +644,16 @@ function* shellServePlan<TEnv>(
643
644
  ) {
644
645
  return { kind: "pass" };
645
646
  }
646
- const descriptor = createShellCaptureDescriptor(
647
- ctx,
647
+ // A MISS's capture after the response, or a HIT's recapture (stale, or a
648
+ // tail that could not finish).
649
+ const capture = createShellCapture(
650
+ input,
648
651
  key,
649
652
  base,
650
- url.origin,
651
653
  pprConfig,
652
- store,
654
+ () => ssrModule,
653
655
  );
656
+ const { descriptor } = capture;
654
657
  if (warm) {
655
658
  // The warm learns its capture's outcome from the capture's own events,
656
659
  // ahead of the router's debugShellCapture sink.
@@ -660,24 +663,27 @@ function* shellServePlan<TEnv>(
660
663
  routerSink?.(event);
661
664
  };
662
665
  }
666
+ // Neither the stored shell nor the build shell is read: a warm renders.
667
+ if (warmReplace) {
668
+ return {
669
+ kind: "miss",
670
+ capture: { ...capture, descriptor: { ...descriptor, force: true } },
671
+ };
672
+ }
663
673
  // MISS (no entry, invalid reactVersion, a tombstone, or store read
664
674
  // failure): axis 1 + a background capture scheduled once the response is
665
675
  // known servable.
666
676
  const miss: ShellServeOutcome = {
667
677
  kind: "miss",
668
- descriptor: { ...descriptor, storedSeqAtRead },
669
- ssrModule,
678
+ capture: { ...capture, descriptor: { ...descriptor, storedSeqAtRead } },
670
679
  };
671
- // Neither the stored shell nor the build shell is read: a warm renders.
672
- if (warmReplace) {
673
- return { ...miss, descriptor: { ...descriptor, force: true } };
674
- }
675
680
 
676
681
  // A HIT replays the handler layer from the entry's doc record (`docKey`),
677
682
  // or, for a Prerender route, from the prerender store. An entry that has
678
683
  // neither is a MISS: serving it would run handlers after the commit.
679
684
  // The entry must have rendered this request's location: the browser
680
685
  // hydrates against the address bar.
686
+ let hit: { document: ShellDocument; recapture: boolean } | undefined;
681
687
  if (
682
688
  cached &&
683
689
  !cached.entry.navigationOnly &&
@@ -700,42 +706,15 @@ function* shellServePlan<TEnv>(
700
706
  "[ShellServe] getShell",
701
707
  );
702
708
  } else {
703
- // An onlyIfStale warm found a servable shell; a recapture scheduled
704
- // below overwrites this through the capture's events.
705
- if (warm) warm.shell = "fresh";
706
709
  // Stale (SWR) hit: serve the stale shell now, recapture in the
707
- // background (stampede-guarded + backoff inside scheduleShellCapture),
710
+ // background (stampede-guarded + backoff inside planShellCapture),
708
711
  // at most once per SHELL_MIN_RECAPTURE_INTERVAL_MS of the shell's age.
709
- if (
710
- cached.shouldRevalidate &&
711
- staleShellRecaptureDue(cached.entry, Date.now())
712
- ) {
713
- yield* handoff("shell-recapture", () =>
714
- scheduleShellCapture(
715
- ctx,
716
- request,
717
- env,
718
- url,
719
- reqCtx,
720
- ssrModule,
721
- descriptor,
722
- ),
723
- );
724
- }
725
- const response = yield* step("shell-hit", () =>
726
- serveShellHit(
727
- ctx,
728
- request,
729
- env,
730
- url,
731
- reqCtx,
732
- handleStore,
733
- ssrModule,
734
- document,
735
- descriptor,
736
- ),
737
- );
738
- return { kind: "serve", response };
712
+ hit = {
713
+ document,
714
+ recapture:
715
+ !!cached.shouldRevalidate &&
716
+ staleShellRecaptureDue(cached.entry, Date.now()),
717
+ };
739
718
  }
740
719
  }
741
720
 
@@ -764,78 +743,59 @@ function* shellServePlan<TEnv>(
764
743
  // server's /__rsc_shell endpoint for PRERENDERED routes only (production's
765
744
  // exact candidate set). Folded away in production builds (NODE_ENV is a
766
745
  // compile-time constant).
767
- const devShellLookup = resolveDevShellLookup(reqCtx, pprConfig);
768
- let buildHit: Awaited<ReturnType<typeof lookupBuildShell>> = null;
769
- if (base.pathname !== undefined) {
746
+ if (!hit && base.pathname === undefined) {
770
747
  // A build shell is the target's: never served under a visitor key.
771
- } else if (key !== baseKey) {
772
- // Probed once per path without a build shell, and not again once the
773
- // route warned. A search-bearing URL never reads one: nothing to probe.
774
- const routeName = reqCtx._classifiedRoute?.routeKey;
775
- if (
776
- !partitionBuildShellCheckDone(ctx.router.id, url.pathname, routeName) &&
777
- shellSearchSeed(url, reqCtx._searchParamsFilter) === ""
778
- ) {
779
- const found = yield* step("build-shell-check", () =>
780
- hasBuildShell(ctx.router.id, url.pathname, devShellLookup),
781
- );
782
- notePartitionBuildShellCheck(
783
- ctx.router.id,
784
- url.pathname,
785
- routeName,
786
- found,
787
- );
788
- }
789
- } else {
790
- buildHit = yield* step("build-shell-lookup", () =>
791
- lookupBuildShell(
792
- ctx.router.id,
793
- url,
794
- ctx.version,
795
- store,
796
- devShellLookup,
797
- reqCtx._searchParamsFilter,
798
- ),
799
- );
800
- }
801
- // The document serve decodes the baked prelude once here; an undecodable
802
- // one is a MISS like a corrupt runtime entry.
803
- const buildDocument = buildHit
804
- ? openShellDocumentMetered(reqCtx, { entry: buildHit.entry })
805
- : null;
806
- if (buildHit && buildDocument) {
807
- if (warm) warm.shell = "fresh";
808
- // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
809
- if (buildHit.stale) {
810
- yield* handoff("shell-recapture", () =>
811
- scheduleShellCapture(
812
- ctx,
813
- request,
814
- env,
748
+ const devShellLookup = resolveDevShellLookup(reqCtx, pprConfig);
749
+ if (key !== baseKey) {
750
+ // Probed once per path without a build shell, and not again once the
751
+ // route warned. A search-bearing URL never reads one: nothing to probe.
752
+ const routeName = reqCtx._classifiedRoute?.routeKey;
753
+ if (
754
+ !partitionBuildShellCheckDone(ctx.router.id, url.pathname, routeName) &&
755
+ shellSearchSeed(url, reqCtx._searchParamsFilter) === ""
756
+ ) {
757
+ const found = yield* step("build-shell-check", () =>
758
+ hasBuildShell(ctx.router.id, url.pathname, devShellLookup),
759
+ );
760
+ notePartitionBuildShellCheck(
761
+ ctx.router.id,
762
+ url.pathname,
763
+ routeName,
764
+ found,
765
+ );
766
+ }
767
+ } else {
768
+ const built = yield* step("build-shell-lookup", () =>
769
+ lookupBuildShell(
770
+ ctx.router.id,
815
771
  url,
816
- reqCtx,
817
- ssrModule,
818
- descriptor,
772
+ ctx.version,
773
+ store,
774
+ devShellLookup,
775
+ reqCtx._searchParamsFilter,
819
776
  ),
820
777
  );
778
+ // The document serve decodes the baked prelude once here; an
779
+ // undecodable one is a MISS like a corrupt runtime entry.
780
+ const document =
781
+ built && openShellDocumentMetered(reqCtx, { entry: built.entry });
782
+ // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
783
+ if (document) hit = { document, recapture: built.stale };
821
784
  }
822
- const response = yield* step("shell-hit", () =>
823
- serveShellHit(
824
- ctx,
825
- request,
826
- env,
827
- url,
828
- reqCtx,
829
- handleStore,
830
- ssrModule,
831
- buildDocument,
832
- descriptor,
833
- ),
834
- );
835
- return { kind: "serve", response };
836
785
  }
786
+ if (!hit) return miss;
837
787
 
838
- return miss;
788
+ // An onlyIfStale warm found a servable shell; a recapture scheduled below
789
+ // overwrites this through the capture's events.
790
+ if (warm) warm.shell = "fresh";
791
+ if (hit.recapture) {
792
+ yield* handoff("shell-recapture", () => planShellCapture(capture));
793
+ }
794
+ const { document } = hit;
795
+ const response = yield* step("shell-hit", () =>
796
+ serveShellHit(input, ssrModule, document, capture),
797
+ );
798
+ return { kind: "serve", response };
839
799
  }
840
800
 
841
801
  /**
@@ -979,117 +939,13 @@ async function matchPartialAndRecordParams<TEnv>(
979
939
  return replay;
980
940
  }
981
941
 
982
- /**
983
- * A document serve's store read: a getShell entry, or a prelude-first read
984
- * (readShellDocument) whose entry is the head and whose snapshot arrives on
985
- * its own promise.
986
- */
987
- type ShellStoreRead =
988
- | { entry: ShellCacheEntry; shouldRevalidate?: boolean; read?: undefined }
989
- | {
990
- entry: ShellEntryHead;
991
- shouldRevalidate?: boolean;
992
- read: ShellDocumentRead;
993
- };
994
-
995
- /**
996
- * Shell store read that degrades to null on failure (axis 1 MISS, never a 500)
997
- * and records the raw `ppr:shell-read` outcome (pre-validity-gates, so a
998
- * version-mismatch lifecycle miss stays distinguishable from a store miss).
999
- * A store with a prelude-first read (readShellDocument) resolves before the
1000
- * snapshot is read, so the HIT commits without waiting for it.
1001
- */
1002
- async function readShellEntry(
1003
- store: SegmentCacheStore & {
1004
- getShell: NonNullable<SegmentCacheStore["getShell"]>;
1005
- },
1006
- key: string,
1007
- reqCtx: ReturnType<typeof getRequestContext>,
1008
- tagHints: readonly string[] | undefined,
1009
- ): Promise<ShellStoreRead | null> {
1010
- let cached: ShellStoreRead | null = null;
1011
- const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
1012
- try {
1013
- if (store.readShellDocument) {
1014
- const read = await store.readShellDocument(
1015
- key,
1016
- tagHints ? { tagHints } : undefined,
1017
- );
1018
- cached = read
1019
- ? { entry: read.entry, shouldRevalidate: read.shouldRevalidate, read }
1020
- : null;
1021
- } else {
1022
- cached = await store.getShell(key);
1023
- }
1024
- } catch (error) {
1025
- reportCacheError(error, "cache-read", "[ShellServe] getShell");
1026
- }
1027
- if (reqCtx._metricsStore) {
1028
- const stats = cached?.read?.stats;
1029
- appendMetric(
1030
- reqCtx._metricsStore,
1031
- "ppr:shell-read",
1032
- shellReadStart,
1033
- performance.now() - shellReadStart,
1034
- undefined,
1035
- cached ? (stats ? `hit ${stats.tier}` : "hit") : "miss",
1036
- );
1037
- if (stats)
1038
- recordShellReadStats(reqCtx._metricsStore, shellReadStart, stats);
1039
- }
1040
- return cached;
1041
- }
1042
-
1043
- /**
1044
- * A prelude-first read's sub-steps as `debugPerformance` rows under
1045
- * `ppr:shell-read`. The marker row starts with the prelude row because the
1046
- * two run in parallel; its desc says how long the read still waited on the
1047
- * marker once the prelude was in. On a deployed worker the clock only
1048
- * advances on I/O, so the byte counts are the cost signal for the CPU parts.
1049
- */
1050
- function recordShellReadStats(
1051
- store: MetricsStore,
1052
- readStart: number,
1053
- stats: ShellReadStats,
1054
- ): void {
1055
- let at = readStart;
1056
- const row = (label: string, ms: number | undefined, desc: string): void => {
1057
- if (ms !== undefined) appendMetric(store, label, at, ms, 1, desc);
1058
- };
1059
- if (stats.memo) {
1060
- row(
1061
- "ppr:shell-memo",
1062
- 0,
1063
- `${stats.memo.hit ? "hit" : "miss"} size=${stats.memo.bytes}b`,
1064
- );
1065
- }
1066
- // A KV hit after an L1 miss: the L1 attempt first, then the KV read.
1067
- row("ppr:shell-l1-miss", stats.l1MissMs, stats.l1MissReason ?? "miss");
1068
- at += stats.l1MissMs ?? 0;
1069
- row("ppr:shell-match", stats.matchMs, stats.tier);
1070
- at += stats.matchMs ?? 0;
1071
- row("ppr:shell-head", stats.headMs, `bytes=${stats.headBytes ?? 0}`);
1072
- at += stats.headMs ?? 0;
1073
- row("ppr:shell-prelude", stats.preludeMs, `bytes=${stats.preludeBytes ?? 0}`);
1074
- let marker = `tags=${stats.tags ?? 0} ${stats.markerSerial ? "serial" : "parallel"} commit-wait=${(stats.markerWaitMs ?? 0).toFixed(2)}ms`;
1075
- if (stats.markerMemo) marker += ` memo=${stats.markerMemo}`;
1076
- if (stats.markerHinted) {
1077
- marker += ` hint=${stats.markerHintHits ?? 0}/${stats.markerHinted.length}`;
1078
- if (stats.markerLeadMs !== undefined) {
1079
- marker += ` lead=${stats.markerLeadMs.toFixed(2)}ms`;
1080
- }
1081
- }
1082
- if (stats.freshReads) marker += " fresh-reads";
1083
- row("ppr:shell-marker", stats.markerMs, marker);
1084
- }
1085
-
1086
942
  /**
1087
943
  * openShellDocument (the HIT's integrity gate and only prelude decode) as a
1088
944
  * `ppr:shell-open` row: CPU only, so it reads 0 on a deployed worker.
1089
945
  */
1090
946
  function openShellDocumentMetered(
1091
947
  reqCtx: RequestContext<any>,
1092
- stored: ShellStoreRead,
948
+ stored: ShellRead,
1093
949
  ): ShellDocument | null {
1094
950
  const read = stored.read;
1095
951
  const open = (): ShellDocument | null =>
@@ -1268,11 +1124,14 @@ function renderPreparedRscResponse<TEnv>(
1268
1124
  // carries deferred units answers one page's prefetch, and a fill is the
1269
1125
  // second half of one adoption: neither is reused by a shared cache or the
1270
1126
  // document cache (which also refuses a payload marked `_payloadDeferred`),
1271
- // whatever Cache-Control the route set. A route with no flag keeps its
1272
- // headers.
1127
+ // whatever Cache-Control the route set; so is a stale partial render, which
1128
+ // a shared cache keyed by URL must not store or serve. A route with no flag
1129
+ // keeps its headers.
1273
1130
  if (
1274
1131
  reqCtx._payloadDeferred ||
1275
- (isPartial && reqCtx._requestKind === "fill")
1132
+ (isPartial &&
1133
+ (reqCtx._requestKind === "fill" ||
1134
+ url.searchParams.get(WIRE.stale) === "true"))
1276
1135
  ) {
1277
1136
  rscHeaders["cache-control"] = NOT_REUSED;
1278
1137
  }
@@ -1333,36 +1192,22 @@ function scheduleNavigationShellCapture<TEnv>(
1333
1192
  input: RequestRenderInput<TEnv>,
1334
1193
  captureKey: string,
1335
1194
  ): void {
1336
- const { ctx, request, env, url, reqCtx } = input;
1195
+ const { ctx, env, url, reqCtx } = input;
1337
1196
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
1338
- const store = reqCtx._cacheStore!;
1339
1197
  const base = requestShellBase(ctx, reqCtx, url);
1340
1198
  if (!base) return;
1341
- scheduleShellCapture(
1342
- ctx,
1343
- request,
1344
- env,
1345
- url,
1346
- reqCtx,
1347
- async (captureRequest, captureUrl) => {
1348
- const [ssrModule, streamMode] = await getSSRSetup(
1349
- ctx,
1350
- captureRequest,
1351
- env,
1352
- captureUrl,
1353
- undefined,
1354
- );
1355
- return streamMode === "allReady" ? null : ssrModule;
1356
- },
1357
- createShellCaptureDescriptor(
1199
+ const ssr = async (captureRequest: Request, captureUrl: URL) => {
1200
+ const [ssrModule, streamMode] = await getSSRSetup(
1358
1201
  ctx,
1359
- captureKey,
1360
- base,
1361
- url.origin,
1362
- pprConfig,
1363
- store,
1364
- true,
1365
- ),
1202
+ captureRequest,
1203
+ env,
1204
+ captureUrl,
1205
+ undefined,
1206
+ );
1207
+ return streamMode === "allReady" ? null : ssrModule;
1208
+ };
1209
+ planShellCapture(
1210
+ createShellCapture(input, captureKey, base, pprConfig, ssr, true),
1366
1211
  );
1367
1212
  }
1368
1213
 
@@ -1460,6 +1305,11 @@ async function matchPartialWithPprReplay<TEnv>(
1460
1305
  if (!getNavigationContextHeader(request, url.origin)) {
1461
1306
  return runMatch({ outcome: "BYPASS", reason: "no-navigation-context" });
1462
1307
  }
1308
+ // A stale partial renders live, as an action does (rendersLive,
1309
+ // router/match-context.ts): the record may predate the action's write.
1310
+ if (url.searchParams.get(WIRE.stale) === "true") {
1311
+ return runMatch({ outcome: "BYPASS", reason: "stale" });
1312
+ }
1463
1313
  // Fragment passthrough (#700), extended to partial replay. Navigation
1464
1314
  // context proves the request can be matched; the separate capability header
1465
1315
  // proves its payload consumer can expand envelopes. Older clients and the
@@ -1486,7 +1336,7 @@ async function matchPartialWithPprReplay<TEnv>(
1486
1336
  //
1487
1337
  // The probe is a PRE-match fast path, not the truth: it reads only the
1488
1338
  // non-intercept artifact, and whether the navigation IS an intercept (and
1489
- // therefore whether tryPrerenderLookup reads `paramHash` or
1339
+ // therefore whether readPrerenderSource reads `paramHash` or
1490
1340
  // `paramHash + "/i"`) is resolved during the match by match-api's
1491
1341
  // findInterceptForRoute — an intercept-source header proves nothing in
1492
1342
  // either direction. runMatch reclassifies every BYPASS from the stamped
@@ -1816,10 +1666,10 @@ async function isSettled(value: unknown): Promise<boolean> {
1816
1666
  * leaves the entry alone.
1817
1667
  */
1818
1668
  async function degradeUnreplayableShell(
1819
- descriptor: ShellCaptureDescriptor,
1669
+ capture: ShellCaptureInput,
1820
1670
  entry: ShellEntryHead,
1821
- scheduleRecapture: () => void,
1822
1671
  ): Promise<void> {
1672
+ const { descriptor } = capture;
1823
1673
  const store = descriptor.store;
1824
1674
  try {
1825
1675
  await store?.putShell?.(
@@ -1838,7 +1688,7 @@ async function degradeUnreplayableShell(
1838
1688
  reportCacheError(error, "cache-write", "[ShellServe] tombstone");
1839
1689
  }
1840
1690
  store?.dropShellMemo?.(descriptor.key);
1841
- scheduleRecapture();
1691
+ planShellCapture(capture);
1842
1692
  }
1843
1693
 
1844
1694
  /**
@@ -1851,16 +1701,13 @@ async function degradeUnreplayableShell(
1851
1701
  * frame; the adapter may pull the stream outside it.
1852
1702
  */
1853
1703
  function serveShellHit(
1854
- ctx: HandlerContext<any>,
1855
- request: Request,
1856
- env: any,
1857
- url: URL,
1858
- reqCtx: RequestContext<any>,
1859
- handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
1704
+ input: RequestRenderInput<any>,
1860
1705
  ssrModule: SSRModule,
1861
1706
  document: ShellDocument,
1862
- descriptor: ShellCaptureDescriptor,
1707
+ capture: ShellCaptureInput,
1863
1708
  ): Response {
1709
+ const { ctx, request, env, url, reqCtx, handleStore } = input;
1710
+ const { descriptor } = capture;
1864
1711
  const { entry, prelude: preludeBytes } = document;
1865
1712
  // A visitor-keyed shell shows the visitor's URL: the document cache keys
1866
1713
  // this response by the target.
@@ -1879,8 +1726,6 @@ function serveShellHit(
1879
1726
  }
1880
1727
  : null;
1881
1728
  const tailT0 = tailTiming ? performance.now() : 0;
1882
- const scheduleRecapture = (): void =>
1883
- scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
1884
1729
  /** Report a broken entry, then tombstone and recapture it. Never throws. */
1885
1730
  const retireEntry = async (reason: string): Promise<void> => {
1886
1731
  reportCacheError(
@@ -1889,7 +1734,7 @@ function serveShellHit(
1889
1734
  "[ShellServe] retire",
1890
1735
  );
1891
1736
  try {
1892
- await degradeUnreplayableShell(descriptor, entry, scheduleRecapture);
1737
+ await degradeUnreplayableShell(capture, entry);
1893
1738
  } catch (error) {
1894
1739
  reportCacheError(error, "cache-write", "[ShellServe] retire");
1895
1740
  }
@@ -1897,26 +1742,6 @@ function serveShellHit(
1897
1742
  /** The first loader pin of the entry that failed to decode (dc-t89). */
1898
1743
  let undecodablePin: string | undefined;
1899
1744
 
1900
- const createTailContext = (): RequestContext<any> => {
1901
- const tailCtx: RequestContext<any> = Object.create(reqCtx);
1902
- // Matching writes render state onto the derived context. Its barrier must
1903
- // close over that same context or a streaming tail inherits the base
1904
- // context's premature non-streaming handle snapshot.
1905
- wireRenderBarrier(tailCtx, handleStore);
1906
- // The tail's render barrier resolves once its record is replayed and
1907
- // before a loader runs (cache-lookup.ts withCacheLookup, yieldFromStore).
1908
- // The store then holds what the prelude was rendered from, so that is
1909
- // the snapshot the document hydrates with; what this request's loaders
1910
- // do to it afterwards reaches the client after hydration
1911
- // (HandleStore.freezeDocumentSnapshot, issue #1035).
1912
- const resolveRenderBarrier = tailCtx._resolveRenderBarrier;
1913
- tailCtx._resolveRenderBarrier = (segments) => {
1914
- handleStore.freezeDocumentSnapshot();
1915
- resolveRenderBarrier(segments);
1916
- };
1917
- return tailCtx;
1918
- };
1919
-
1920
1745
  const renderTail = async (
1921
1746
  activeCtx: RequestContext<any>,
1922
1747
  ): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
@@ -2028,7 +1853,6 @@ function serveShellHit(
2028
1853
  tailTiming.snapshotParseMs = Math.round(snapshotStats.parseMs);
2029
1854
  }
2030
1855
  }
2031
- const seededCtx = createTailContext();
2032
1856
  const records = snapshot ?? [];
2033
1857
  // Loader-family records (bake-lane containers, loader-container-bake):
2034
1858
  // decode into a seed Map for the resolveLoaderData overlay, so the
@@ -2052,32 +1876,31 @@ function serveShellHit(
2052
1876
  `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
2053
1877
  );
2054
1878
  }
2055
- if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
2056
- // The handler layer is REPLAYED from the entry's own doc record, never
2057
- // executed: the implicit doc scope replaces any route-derived scope
2058
- // (docTail), looks the record up by the key the capture wrote
2059
- // (fixedDocKey), and reads it from a segment-only overlay of the snapshot
2060
- // (the real store never holds a doc: record). A lookup that does not hit
2061
- // throws ShellRecordUnavailableError (withCacheLookup), handled by the
2062
- // degrade below. Live and promise-carrying bake-lane loaders still run
2063
- // fresh; a promise-free bake-lane loader is served from its pin
2064
- // (_shellLoaderSeed, loader-cache.ts). A Prerender route's tail takes
2065
- // the handler layer from the prerender store before this scope is
2066
- // consulted, and the loader pushes from `prerenderHandles`.
2067
- seededCtx._shellImplicitCache = {
2068
- ttl: descriptor.ttl,
2069
- swr: descriptor.swr,
2070
- store: new SeededShellStore(reqCtx._cacheStore!, records),
2071
- keyPrefix: "doc",
2072
- docTail: true,
2073
- fixedDocKey: entry.docKey,
2074
- prerenderHandles: shellPrerenderHandles(records),
2075
- };
2076
- // Fragment splice (issue #700): store hits in THIS tail emit their stored
2077
- // segment fragments verbatim (expanded by segment-fragments.ts). The flag
2078
- // lives on the derived context so it can never leak into a capture render,
2079
- // which serializes segments and must see real elements.
2080
- seededCtx._shellFragmentPayload = true;
1879
+ // What the tail owns, and why: RENDER_PASS_FIELDS["ppr-tail"].
1880
+ const seededCtx = deriveRenderPass(reqCtx, "ppr-tail", {
1881
+ handleStore,
1882
+ loaderSeed,
1883
+ // The handler layer is REPLAYED from the entry's own doc record, never
1884
+ // executed: the implicit doc scope replaces any route-derived scope
1885
+ // (docTail), looks the record up by the key the capture wrote
1886
+ // (fixedDocKey), and reads it from a segment-only overlay of the
1887
+ // snapshot (the real store never holds a doc: record). A lookup that
1888
+ // does not hit throws ShellRecordUnavailableError (withCacheLookup),
1889
+ // handled by the degrade below. Live and promise-carrying bake-lane
1890
+ // loaders still run fresh; a promise-free bake-lane loader is served
1891
+ // from its pin (_shellLoaderSeed, loader-cache.ts). A Prerender route's
1892
+ // tail takes the handler layer from the prerender store before this
1893
+ // scope is consulted, and the loader pushes from `prerenderHandles`.
1894
+ implicitCache: {
1895
+ ttl: descriptor.ttl,
1896
+ swr: descriptor.swr,
1897
+ store: new SeededShellStore(reqCtx._cacheStore!, records),
1898
+ keyPrefix: "doc",
1899
+ docTail: true,
1900
+ fixedDocKey: entry.docKey,
1901
+ prerenderHandles: shellPrerenderHandles(records),
1902
+ },
1903
+ });
2081
1904
  return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
2082
1905
  })();
2083
1906
  // The stream below is the only consumer; pre-attach a no-op catch so a tail
@@ -2222,8 +2045,8 @@ function serveShellHit(
2222
2045
  // here AFTER the 200 + prelude flushed and would re-fail on every
2223
2046
  // request until the entry ages out — recapture overwrites the entry.
2224
2047
  // Client disconnects land here too; the recapture is idempotent and
2225
- // bounded by scheduleShellCapture's stampede guard + backoff.
2226
- scheduleRecapture();
2048
+ // bounded by planShellCapture's stampede guard + backoff.
2049
+ planShellCapture(capture);
2227
2050
  if (tailTiming) {
2228
2051
  tailTiming.outcome = "error";
2229
2052
  tailTiming.completeMs = Math.round(performance.now() - tailT0);