@rangojs/router 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -1,26 +1,24 @@
1
1
  /**
2
2
  * Capture data snapshot: recording + seeding stores for PPR shell parity.
3
3
  *
4
- * The scar tissue this fixes: a PPR HIT serves frozen prelude bytes, then a
5
- * FULL FRESH Flight render for hydration. Any shell-baked (non-hole) content
6
- * that drifts between capture time and hit time — a cache() segment with a
7
- * shorter ttl than the shell, a tag-invalidated item — makes the fresh payload
8
- * disagree with the prelude, so React throws a hydration text mismatch and
9
- * regenerates the tree client-side (wiping the FOUC theme class, flashing
10
- * content). See docs/design/ppr-shell-resume.md.
4
+ * A PPR HIT serves frozen prelude bytes, then a Flight payload for hydration
5
+ * that must agree with them byte for byte. The handler layer's part of that
6
+ * payload is the capture's own doc segment record: the capture writes it
7
+ * first, renders its prelude from it, and every HIT replays it (no handler
8
+ * runs on a HIT). The loader layer's part is the bake-lane (`ssr: false`)
9
+ * loader pins: a promise-free bake-lane loader is served from its pin and
10
+ * does not run; one whose return carries promises (`holes`), or whose pushes
11
+ * the record could not keep (`runs`), runs on the HIT. See
12
+ * docs/design/ppr-shell-resume.md.
11
13
  *
12
- * The fix (Next.js resume-data-cache analog, adapted to Rango's cache rings):
13
- * the CAPTURE render records every cache-store read-hit and write it performed
14
- * (the {@link RecordingShellStore}); the record rides inside the ShellCacheEntry
15
- * as its `snapshot`; on a HIT the tail render reads through a
16
- * {@link SeededShellStore} overlay that serves those recorded values AS FRESH,
17
- * so the shell region reproduces byte-identically while everything NOT recorded
18
- * (the holes — masked loaders were never executed at capture, so their reads
19
- * were never recorded) stays live.
20
- *
21
- * The invariant, verbatim: the snapshot is exactly the set of cache-store reads
22
- * the capture render performed; replaying them on a HIT reproduces the shell
23
- * content byte-identically; everything not recorded stays live.
14
+ * The invariant, verbatim: the snapshot is the doc record and the bake-lane
15
+ * loader pins; replaying them on a HIT reproduces the shell content
16
+ * byte-identically; everything else a HIT reads comes from the store. A hole
17
+ * (a live-lane loader, or a promise nested in a bake-lane return) never reads
18
+ * the capture's copy of a value: it is dynamic or has its own cache. A
19
+ * bake-lane loader body that runs on a HIT reads the store too; its pinned
20
+ * container paths stay the pin's (overlayLoaderContainer), and its settled
21
+ * pushes stay the record's (HandleStore.pushRestored).
24
22
  */
25
23
 
26
24
  import type {
@@ -31,63 +29,45 @@ import type {
31
29
  CachedEntryData,
32
30
  ShellCacheEntry,
33
31
  ShellSnapshotRecord,
34
- ShellSnapshotItemValue,
35
- ShellSnapshotResponseValue,
36
32
  ShellSnapshotLoaderValue,
37
33
  CacheReadError,
38
34
  } from "./types.js";
39
- import { CACHE_READ_ERROR } from "./types.js";
40
- import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
41
- import { isPerClientSignalHeader } from "../browser/cookie-name.js";
42
-
43
- /** Compose the last-write-wins map key. NUL (`\u0000`) cannot appear in a cache key. */
44
- function recordKey(family: ShellSnapshotRecord["family"], key: string): string {
45
- return `${family}\u0000${key}`;
46
- }
35
+ import { settleGrowing } from "./background-task.js";
47
36
 
48
- /** Serialize a Response to the snapshot's stored shape (base64 body). */
49
- async function serializeResponse(
50
- response: Response,
51
- ): Promise<ShellSnapshotResponseValue> {
52
- const body = await response.clone().arrayBuffer();
53
- const headers: [string, string][] = [];
54
- response.headers.forEach((value, name) => {
55
- // Mirror putResponse: per-client signal headers never enter a shared entry.
56
- if (isPerClientSignalHeader(name)) return;
57
- headers.push([name, value]);
58
- });
59
- return { status: response.status, headers, body: bufferToBase64(body) };
60
- }
61
-
62
- /** Rebuild a live Response from a snapshot's stored response shape. */
63
- function deserializeResponse(value: ShellSnapshotResponseValue): Response {
64
- return new Response(base64ToBuffer(value.body), {
65
- status: value.status,
66
- headers: new Headers(value.headers),
67
- });
37
+ /**
38
+ * The inner store's isTagsInvalidatedSince, absent when it has none. A
39
+ * capture runs on RecordingShellStore as the request's store, and the writes
40
+ * it passes through gate on it (#977, tag-invalidation.ts
41
+ * predatesInvalidation).
42
+ */
43
+ function forwardTagHistory<TEnv>(
44
+ inner: SegmentCacheStore<TEnv>,
45
+ ): SegmentCacheStore<TEnv>["isTagsInvalidatedSince"] {
46
+ return inner.isTagsInvalidatedSince
47
+ ? (tags, sinceMs, options) =>
48
+ inner.isTagsInvalidatedSince!(tags, sinceMs, options)
49
+ : undefined;
68
50
  }
69
51
 
70
52
  /**
71
- * A store wrapper the CAPTURE render reads through. Every call passes through to
72
- * the underlying store unchanged; for the item/segment/response families it also
73
- * RECORDS, last-write-wins per (family, key):
74
- * - read-hits (get/getItem/getResponse returning non-null) — the value that
75
- * fed the shell,
76
- * - writes (set/setItem/putResponse) — the value a MISS computed and baked.
77
- * The shell family (getShell/putShell) is never recorded (the snapshot rides
78
- * inside a shell entry). Reads that MISS are not recorded (a miss produced no
79
- * shell content; if the render then computed and wrote, that write is recorded).
53
+ * A store wrapper the CAPTURE render reads through. Every call passes through
54
+ * to the underlying store unchanged; the one thing it records is the doc
55
+ * segment record ({@link recordSegmentWrite}). No cache read is recorded: a
56
+ * HIT replays the handler layer from the doc record and serves a promise-free
57
+ * bake-lane loader from its pin, and everything else a HIT reads (a hole, a
58
+ * bake-lane loader body that runs there) comes from the store.
80
59
  *
81
60
  * Deferred writes: cache writes run under waitUntil (fire-and-forget on Node,
82
- * executionContext on workerd), so their setItem/set calls — hence their records
83
- * — may land after the shell has quiesced. The capture collects those write
84
- * promises via {@link trackWrite} and awaits them ({@link settleWrites}) before
85
- * draining, so a MISS-at-capture value is still pinned.
61
+ * executionContext on workerd), so the doc record's write may land after the
62
+ * capture's match returns. The capture collects those write promises via
63
+ * {@link trackWrite} and awaits them ({@link settleWrites}) before reading the
64
+ * record and draining.
86
65
  */
87
66
  export class RecordingShellStore<
88
67
  TEnv = unknown,
89
68
  > implements SegmentCacheStore<TEnv> {
90
- private readonly records = new Map<string, ShellSnapshotRecord>();
69
+ /** The recorded segment records by key, last write wins. */
70
+ private readonly records = new Map<string, CachedEntryData>();
91
71
  private readonly writes: Promise<unknown>[] = [];
92
72
 
93
73
  constructor(private readonly inner: SegmentCacheStore<TEnv>) {}
@@ -101,13 +81,8 @@ export class RecordingShellStore<
101
81
  get supportsPassiveShellReads(): true | undefined {
102
82
  return this.inner.supportsPassiveShellReads;
103
83
  }
104
-
105
- private record(
106
- family: ShellSnapshotRecord["family"],
107
- key: string,
108
- value: ShellSnapshotRecord["value"],
109
- ): void {
110
- this.records.set(recordKey(family, key), { family, key, value });
84
+ get isTagsInvalidatedSince(): SegmentCacheStore<TEnv>["isTagsInvalidatedSince"] {
85
+ return forwardTagHistory(this.inner);
111
86
  }
112
87
 
113
88
  /** Track a deferred cache-write promise so the capture can await it pre-drain. */
@@ -116,56 +91,50 @@ export class RecordingShellStore<
116
91
  }
117
92
 
118
93
  /**
119
- * Record a segment-family write into the snapshot WITHOUT touching the inner
120
- * store. The shell fast path's implicit doc-cache scope writes through this
121
- * (via {@link SnapshotOnlySegmentStore}): the recorded doc entry must ride
122
- * ONLY inside the shell entry — a passthrough write would leave a doc-keyed
123
- * entry in the real store that the NEXT capture's lookup would hit, replaying
124
- * the previous generation's segments instead of re-running handlers (breaking
94
+ * Record the doc segment record into the snapshot WITHOUT touching the
95
+ * inner store. The capture's implicit doc-cache scope writes through this
96
+ * (via {@link SnapshotOnlySegmentStore}): the record must ride ONLY inside
97
+ * the shell entry — a passthrough write would leave a doc-keyed entry in the
98
+ * real store that the NEXT capture's lookup would hit, replaying the
99
+ * previous generation's segments instead of re-running handlers (breaking
125
100
  * SWR recapture freshness).
126
101
  */
127
102
  recordSegmentWrite(key: string, data: CachedEntryData): void {
128
- this.record("segment", key, data);
103
+ this.records.set(key, data);
129
104
  }
130
105
 
131
106
  /**
132
- * Await the tracked deferred writes so their records are present before drain.
133
- * Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
134
- * cacheRoute path schedules its actual store.set in a second waitUntil while the
135
- * first is running), so each awaited batch may enqueue more. Loop until the
136
- * queue empties or the deadline passes. Bounded: a pathologically slow write
137
- * must never stall the capture task, so a key that does not settle in time is
138
- * left unpinned (it drifts, the pre-snapshot behavior) rather than hanging.
107
+ * Await the tracked deferred writes so their records are present before
108
+ * drain, including a NESTED write a settled one scheduled (the ring-3
109
+ * cacheRoute path schedules its actual store.set in a second waitUntil).
110
+ * Bounded: a pathologically slow write must never stall the capture task.
111
+ * Resolves true when every tracked write settled, false at the deadline
112
+ * (settleCaptureRecord then fails the capture when the doc record is among
113
+ * the writes still pending); a later call still waits for what is pending.
139
114
  */
140
- async settleWrites(timeoutMs: number): Promise<void> {
141
- const deadline = Date.now() + timeoutMs;
142
- while (this.writes.length > 0) {
143
- const remaining = deadline - Date.now();
144
- if (remaining <= 0) return;
145
- // Take the current batch; new writes scheduled while awaiting accumulate in
146
- // this.writes and are drained on the next iteration.
147
- const batch = this.writes.splice(0);
148
- let timer: ReturnType<typeof setTimeout> | undefined;
149
- const guard = new Promise<void>((resolve) => {
150
- timer = setTimeout(resolve, remaining);
151
- (timer as { unref?: () => void }).unref?.();
152
- });
153
- await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
154
- if (timer) clearTimeout(timer);
155
- }
115
+ settleWrites(timeoutMs: number): Promise<boolean> {
116
+ return settleGrowing(this.writes, Date.now() + timeoutMs);
156
117
  }
157
118
 
158
- /** The recorded snapshot (last-write-wins per family+key), or undefined if empty. */
119
+ /** The recorded segment records, or undefined if none. */
159
120
  drainSnapshot(): ShellSnapshotRecord[] | undefined {
160
- return this.records.size > 0 ? [...this.records.values()] : undefined;
121
+ if (this.records.size === 0) return undefined;
122
+ return [...this.records].map(
123
+ ([key, value]): ShellSnapshotRecord => ({
124
+ family: "segment",
125
+ key,
126
+ value,
127
+ }),
128
+ );
129
+ }
130
+
131
+ /** One recorded segment record, by key. */
132
+ getRecord(key: string): CachedEntryData | undefined {
133
+ return this.records.get(key);
161
134
  }
162
135
 
163
136
  async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
164
- const result = await this.inner.get(key);
165
- if (result && result !== CACHE_READ_ERROR) {
166
- this.record("segment", key, result.data);
167
- }
168
- return result;
137
+ return this.inner.get(key);
169
138
  }
170
139
 
171
140
  async set(
@@ -174,7 +143,6 @@ export class RecordingShellStore<
174
143
  ttl: number,
175
144
  swr?: number,
176
145
  ): Promise<void> {
177
- this.record("segment", key, data);
178
146
  return this.inner.set(key, data, ttl, swr);
179
147
  }
180
148
 
@@ -189,11 +157,7 @@ export class RecordingShellStore<
189
157
  async getResponse(
190
158
  key: string,
191
159
  ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
192
- if (!this.inner.getResponse) return null;
193
- const result = await this.inner.getResponse(key);
194
- if (result)
195
- this.record("response", key, await serializeResponse(result.response));
196
- return result;
160
+ return this.inner.getResponse ? this.inner.getResponse(key) : null;
197
161
  }
198
162
 
199
163
  async putResponse(
@@ -203,23 +167,11 @@ export class RecordingShellStore<
203
167
  swr?: number,
204
168
  tags?: string[],
205
169
  ): Promise<void> {
206
- if (!this.inner.putResponse) return;
207
- this.record("response", key, await serializeResponse(response));
208
- return this.inner.putResponse(key, response, ttl, swr, tags);
170
+ return this.inner.putResponse?.(key, response, ttl, swr, tags);
209
171
  }
210
172
 
211
173
  async getItem(key: string): Promise<CacheItemResult | null> {
212
- if (!this.inner.getItem) return null;
213
- const result = await this.inner.getItem(key);
214
- if (result) {
215
- const value: ShellSnapshotItemValue = {
216
- value: result.value,
217
- handles: result.handles,
218
- tags: result.tags,
219
- };
220
- this.record("item", key, value);
221
- }
222
- return result;
174
+ return this.inner.getItem ? this.inner.getItem(key) : null;
223
175
  }
224
176
 
225
177
  async setItem(
@@ -227,14 +179,7 @@ export class RecordingShellStore<
227
179
  value: string,
228
180
  options?: CacheItemOptions,
229
181
  ): Promise<void> {
230
- if (!this.inner.setItem) return;
231
- const stored: ShellSnapshotItemValue = {
232
- value,
233
- handles: options?.handles,
234
- tags: options?.tags,
235
- };
236
- this.record("item", key, stored);
237
- return this.inner.setItem(key, value, options);
182
+ return this.inner.setItem?.(key, value, options);
238
183
  }
239
184
 
240
185
  async getShell(
@@ -259,7 +204,7 @@ export class RecordingShellStore<
259
204
  }
260
205
  }
261
206
 
262
- /** True iff `store` is a RecordingShellStore (duck-typed across module copies). */
207
+ /** `store` when it is a RecordingShellStore (an instanceof check). */
263
208
  export function getRecordingStore<TEnv>(
264
209
  store: SegmentCacheStore<TEnv> | undefined,
265
210
  ): RecordingShellStore<TEnv> | undefined {
@@ -267,13 +212,119 @@ export function getRecordingStore<TEnv>(
267
212
  }
268
213
 
269
214
  /**
270
- * The store the shell fast path's IMPLICIT doc-cache scope resolves during a
271
- * capture: reads pass through the recording store (a real-store hit is
272
- * recorded, exactly like any capture read), but segment WRITES are recorded
273
- * into the snapshot only — see {@link RecordingShellStore.recordSegmentWrite}
274
- * for why passthrough would break SWR recapture. Routes with their OWN
275
- * cache() config never see this store (their scope resolves the app-level
276
- * recording store and keeps today's record-and-write behavior).
215
+ * True when `snapshot` carries the canonical doc segment record `docKey`
216
+ * names, with at least one segment: the record partial replay and a document
217
+ * HIT tail consume.
218
+ */
219
+ export function hasDocRecord(
220
+ snapshot: readonly ShellSnapshotRecord[] | undefined,
221
+ docKey: string | undefined,
222
+ ): boolean {
223
+ if (docKey === undefined || !snapshot) return false;
224
+ return snapshot.some((record) => {
225
+ if (
226
+ !record ||
227
+ typeof record !== "object" ||
228
+ record.family !== "segment" ||
229
+ record.key !== docKey ||
230
+ typeof record.value !== "object" ||
231
+ record.value === null
232
+ ) {
233
+ return false;
234
+ }
235
+ const segments = (record.value as { segments?: unknown }).segments;
236
+ return Array.isArray(segments) && segments.length > 0;
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Split a capture's snapshot into what a HIT or a partial replay reads and
242
+ * the rest (docs/design/shell-entry-layout.md §2). Kept: the doc record
243
+ * (`docKey`), as a copy inside the entry (a separately stored record could be
244
+ * evicted on its own, and the HIT would have nothing to replay), and the
245
+ * loader-family pins unless the entry is navigation-only (partial replay of
246
+ * such an entry seeds the doc record alone). Every other record goes.
247
+ */
248
+ export function pruneShellSnapshot(
249
+ snapshot: readonly ShellSnapshotRecord[],
250
+ navigationOnly: boolean,
251
+ docKey: string | undefined,
252
+ ): { kept: ShellSnapshotRecord[]; pruned: ShellSnapshotRecord[] } {
253
+ const kept: ShellSnapshotRecord[] = [];
254
+ const pruned: ShellSnapshotRecord[] = [];
255
+ for (const record of snapshot) {
256
+ const keep =
257
+ (record.family === "segment" && record.key === docKey) ||
258
+ (record.family === "loader" && !navigationOnly);
259
+ (keep ? kept : pruned).push(record);
260
+ }
261
+ return { kept, pruned };
262
+ }
263
+
264
+ /**
265
+ * The shell entry head's fields estimateShellEntryBytes does not measure
266
+ * (versions, timestamps, theme, lengths, flags; about 200 bytes in a
267
+ * CFCacheStore frame) and the frame's 12-byte prefix, with room to spare.
268
+ */
269
+ export const SHELL_ENTRY_HEAD_ALLOWANCE_BYTES: number = 512;
270
+
271
+ const ENTRY_BYTE_ENCODER = new TextEncoder();
272
+
273
+ /**
274
+ * Bytes (UTF-8) a stored shell entry takes, as the built-in stores write it:
275
+ * the prelude, the snapshot's JSON, and a JSON head that carries the
276
+ * postponed state as an escaped string next to the tags and doc key
277
+ * (CFCacheStore's frame head, VercelCacheStore's envelope), plus
278
+ * SHELL_ENTRY_HEAD_ALLOWANCE_BYTES for the head's fixed fields and the
279
+ * frame prefix. Measuring the raw postponed bytes undercounted: every quote
280
+ * in it is escaped in the head (a /ppr-large/holes entry measured 1,098
281
+ * bytes under its KV value). The capture's whole-entry guard compares this
282
+ * with the store's value limit.
283
+ */
284
+ export function estimateShellEntryBytes(parts: {
285
+ preludeBytes: number;
286
+ postponed: string | null | undefined;
287
+ snapshot: readonly ShellSnapshotRecord[] | undefined;
288
+ tags: readonly string[] | undefined;
289
+ docKey: string | undefined;
290
+ prunedRecords: string | undefined;
291
+ }): number {
292
+ const head = ENTRY_BYTE_ENCODER.encode(
293
+ JSON.stringify({
294
+ po: parts.postponed,
295
+ t: parts.tags,
296
+ dk: parts.docKey,
297
+ pr: parts.prunedRecords,
298
+ }),
299
+ ).length;
300
+ const snapshot =
301
+ parts.snapshot && parts.snapshot.length > 0
302
+ ? ENTRY_BYTE_ENCODER.encode(JSON.stringify(parts.snapshot)).length
303
+ : 0;
304
+ return (
305
+ parts.preludeBytes + head + snapshot + SHELL_ENTRY_HEAD_ALLOWANCE_BYTES
306
+ );
307
+ }
308
+
309
+ /** Snapshot records by family, e.g. `segment:1/loader:2` (no commas: it rides a Server-Timing desc). */
310
+ export function countSnapshotFamilies(
311
+ snapshot: readonly ShellSnapshotRecord[],
312
+ ): string {
313
+ const counts = new Map<string, number>();
314
+ for (const record of snapshot) {
315
+ counts.set(record.family, (counts.get(record.family) ?? 0) + 1);
316
+ }
317
+ return [...counts].map(([family, n]) => `${family}:${n}`).join("/");
318
+ }
319
+
320
+ /**
321
+ * The store the capture's IMPLICIT doc-cache scope resolves: reads pass
322
+ * through the recording store to the real one, but segment WRITES are
323
+ * recorded into the snapshot only — see
324
+ * {@link RecordingShellStore.recordSegmentWrite} for why passthrough would
325
+ * break SWR recapture. A route with its OWN cache() scope keeps that scope's
326
+ * real-store reads and writes; recordShellCaptureDocRecord (cache-store.ts)
327
+ * writes its doc record through this store too.
277
328
  */
278
329
  export class SnapshotOnlySegmentStore<
279
330
  TEnv = unknown,
@@ -300,32 +351,38 @@ export class SnapshotOnlySegmentStore<
300
351
  }
301
352
  }
302
353
 
354
+ /** One decoded loader-family pin (buildShellLoaderSeed). */
355
+ export interface ShellLoaderSeedEntry {
356
+ /** The Flight-deserialized elided container (recorded paths + markers). */
357
+ container: unknown;
358
+ /**
359
+ * True when the record carries hole markers: the HIT overlay must gate on
360
+ * the fresh run. False = fully pinned; the payload promise resolves
361
+ * immediately from the pin (loader-cache.ts pin-first path).
362
+ */
363
+ holes: boolean;
364
+ /**
365
+ * The record needs its loader body to run on a HIT for pushes the capture
366
+ * could not record (ShellSnapshotLoaderValue.runs). Only read for a
367
+ * hole-free record: a hole-carrying one always runs.
368
+ */
369
+ runs: boolean;
370
+ }
371
+
303
372
  /**
304
373
  * Materialize the loader-family seed from a shell snapshot for a HIT's tail
305
374
  * render: Flight-deserialize each recorded (promise-elided) bake-lane
306
375
  * container into a segment-key -> container Map, which serveShellHit assigns
307
376
  * to the tail context's `_shellLoaderSeed` for the resolveLoaderData overlay.
308
- * Lives here so every snapshot family is decoded in this module (the
309
- * item/segment/response families via {@link SeededShellStore}); the loader
310
- * family is not a store read, so it seeds the context instead of a store.
377
+ * Lives here so every snapshot family is decoded in this module (the segment
378
+ * family via {@link SeededShellStore}); the loader family is not a store
379
+ * read, so it seeds the context instead of a store.
311
380
  *
312
381
  * Deserializations run in parallel; a record that fails to decode is skipped
313
382
  * (that loader drifts — the pre-snapshot behavior — instead of failing the
314
383
  * HIT). Returns undefined when the snapshot carries no loader records, without
315
384
  * touching the Flight codec (kept lazy for cold paths and non-RSC configs).
316
385
  */
317
- export interface ShellLoaderSeedEntry {
318
- /** The Flight-deserialized elided container (recorded paths + markers). */
319
- container: unknown;
320
- /**
321
- * True when the record carries hole markers (or predates the capture-side
322
- * hole bit, where hole-ness is unknown): the HIT overlay must gate on the
323
- * fresh run. False = fully pinned; the payload promise resolves immediately
324
- * from the pin (loader-cache.ts pin-first path).
325
- */
326
- holes: boolean;
327
- }
328
-
329
386
  export async function buildShellLoaderSeed(
330
387
  snapshot: ShellSnapshotRecord[],
331
388
  ): Promise<Map<string, ShellLoaderSeedEntry> | undefined> {
@@ -345,9 +402,11 @@ export async function buildShellLoaderSeed(
345
402
  rec.key,
346
403
  {
347
404
  container: await deserializeResult(stored.value),
348
- // A record without the bit (pre-bit snapshot) reads as
349
- // hole-carrying: unknown hole-ness must keep the gated path.
405
+ // A record stored before the bits existed (v0.17) lacks the
406
+ // loader-owned pushes: it reads as hole-carrying and as runs,
407
+ // so the loader body supplies them.
350
408
  holes: stored.holes !== 0,
409
+ runs: stored.runs !== 0,
351
410
  },
352
411
  ];
353
412
  } catch {
@@ -364,50 +423,29 @@ export async function buildShellLoaderSeed(
364
423
  }
365
424
 
366
425
  /**
367
- * A read-through overlay the HIT tail render reads through. For a key present in
368
- * the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
369
- * a pinned key must NOT kick SWR background revalidation) so the tail's payload
370
- * matches the frozen prelude. Every other read falls through to the real store
371
- * (the holes — masked loaders were never recorded — stay live). Writes pass
372
- * through so a live hole may legitimately write, except that `segmentsOnly`
373
- * fully isolates the segment family: misses do not fall through, and writes or
374
- * deletes stay local to the navigation overlay. The shell family always passes
375
- * through. With `segmentsOnly`, item and response reads also pass through so a
376
- * partial navigation cannot freeze captured data or write a partial result into
377
- * the canonical document namespace.
426
+ * The implicit doc scope's store on a HIT tail and on a partial replay: it
427
+ * serves the snapshot's segment records, the doc record among them, and
428
+ * fully isolates the segment family. Misses do not fall through, and writes
429
+ * or deletes stay local to the overlay, so a partial navigation cannot write
430
+ * a partial result into the canonical document namespace. It carries nothing
431
+ * else: every other read on a HIT (a hole, a bake-lane loader body that runs
432
+ * there) resolves the request's own store. Loader-family records are not
433
+ * store reads: serveShellHit seeds them onto the tail context
434
+ * (_shellLoaderSeed) for the resolveLoaderData overlay instead.
378
435
  */
379
436
  export class SeededShellStore<
380
437
  TEnv = unknown,
381
438
  > implements SegmentCacheStore<TEnv> {
382
- private readonly items: Map<string, ShellSnapshotItemValue> | undefined;
383
439
  private readonly segments = new Map<string, CachedEntryData>();
384
- private readonly responses:
385
- | Map<string, ShellSnapshotResponseValue>
386
- | undefined;
387
- private readonly segmentsOnly: boolean;
388
440
 
389
441
  constructor(
390
442
  private readonly inner: SegmentCacheStore<TEnv>,
391
- snapshot: ShellSnapshotRecord[],
392
- options?: { segmentsOnly?: boolean },
443
+ snapshot: readonly ShellSnapshotRecord[],
393
444
  ) {
394
- this.segmentsOnly = options?.segmentsOnly === true;
395
- this.items = this.segmentsOnly ? undefined : new Map();
396
- this.responses = this.segmentsOnly ? undefined : new Map();
397
445
  for (const rec of snapshot) {
398
- if (!rec || typeof rec !== "object") continue;
399
- if (rec.family === "segment") {
446
+ if (rec && typeof rec === "object" && rec.family === "segment") {
400
447
  this.segments.set(rec.key, rec.value as CachedEntryData);
401
- } else if (this.segmentsOnly) {
402
- continue;
403
- } else if (rec.family === "item") {
404
- this.items?.set(rec.key, rec.value as ShellSnapshotItemValue);
405
- } else if (rec.family === "response") {
406
- this.responses?.set(rec.key, rec.value as ShellSnapshotResponseValue);
407
448
  }
408
- // "loader" family records are not store reads — serveShellHit seeds them
409
- // onto the tail context (_shellLoaderSeed) for the resolveLoaderData
410
- // overlay instead.
411
449
  }
412
450
  }
413
451
 
@@ -417,100 +455,34 @@ export class SeededShellStore<
417
455
  get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
418
456
  return this.inner.keyGenerator;
419
457
  }
420
- get supportsPassiveShellReads(): true | undefined {
421
- return this.inner.supportsPassiveShellReads;
422
- }
423
458
 
424
459
  async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
425
460
  const seeded = this.segments.get(key);
426
- if (seeded) return { data: seeded, shouldRevalidate: false };
427
- if (this.segmentsOnly) return null;
428
- return this.inner.get(key);
461
+ return seeded ? { data: seeded, shouldRevalidate: false } : null;
429
462
  }
430
463
 
431
- async set(
432
- key: string,
433
- data: CachedEntryData,
434
- ttl: number,
435
- swr?: number,
436
- ): Promise<void> {
437
- if (this.segmentsOnly) {
438
- this.segments.set(key, data);
439
- return;
440
- }
441
- return this.inner.set(key, data, ttl, swr);
464
+ async set(key: string, data: CachedEntryData): Promise<void> {
465
+ this.segments.set(key, data);
442
466
  }
443
467
 
444
468
  async delete(key: string): Promise<boolean> {
445
- if (this.segmentsOnly) {
446
- return this.segments.delete(key);
447
- }
448
- return this.inner.delete(key);
449
- }
450
-
451
- async clear(): Promise<void> {
452
- return this.inner.clear?.();
453
- }
454
-
455
- async getResponse(
456
- key: string,
457
- ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
458
- const seeded = this.responses?.get(key);
459
- if (seeded) {
460
- return { response: deserializeResponse(seeded), shouldRevalidate: false };
461
- }
462
- return this.inner.getResponse ? this.inner.getResponse(key) : null;
463
- }
464
-
465
- async putResponse(
466
- key: string,
467
- response: Response,
468
- ttl: number,
469
- swr?: number,
470
- tags?: string[],
471
- ): Promise<void> {
472
- return this.inner.putResponse?.(key, response, ttl, swr, tags);
473
- }
474
-
475
- async getItem(key: string): Promise<CacheItemResult | null> {
476
- const seeded = this.items?.get(key);
477
- if (seeded) {
478
- return {
479
- value: seeded.value,
480
- handles: seeded.handles,
481
- tags: seeded.tags,
482
- shouldRevalidate: false,
483
- };
484
- }
485
- return this.inner.getItem ? this.inner.getItem(key) : null;
486
- }
487
-
488
- async setItem(
489
- key: string,
490
- value: string,
491
- options?: CacheItemOptions,
492
- ): Promise<void> {
493
- return this.inner.setItem?.(key, value, options);
494
- }
495
-
496
- async getShell(
497
- key: string,
498
- options?: { claimRevalidation?: boolean },
499
- ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
500
- return this.inner.getShell ? this.inner.getShell(key, options) : null;
501
- }
502
-
503
- async putShell(
504
- key: string,
505
- entry: ShellCacheEntry,
506
- ttlSeconds?: number,
507
- swrSeconds?: number,
508
- tags?: string[],
509
- ): Promise<"stored" | "invalidated" | "uncacheable" | void> {
510
- return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
469
+ return this.segments.delete(key);
511
470
  }
471
+ }
512
472
 
513
- async invalidateTags(tags: string[]): Promise<void> {
514
- return this.inner.invalidateTags?.(tags);
473
+ /**
474
+ * Thrown by a document HIT tail's match when its doc record cannot supply the
475
+ * handler layer (it failed to decode, or the entry lost it). The tail must not
476
+ * run handlers after the prelude committed, so withCacheLookup throws this
477
+ * instead of resolving segments, and serveShellHit degrades the response:
478
+ * the entry is replaced with a tombstone, the memo is dropped, a recapture is
479
+ * scheduled, and the browser reloads into a MISS.
480
+ */
481
+ export class ShellRecordUnavailableError extends Error {
482
+ constructor(docKey: string | undefined) {
483
+ super(
484
+ `PPR shell HIT: the doc record${docKey ? ` "${docKey}"` : ""} could not supply the handler layer`,
485
+ );
486
+ this.name = "ShellRecordUnavailableError";
515
487
  }
516
488
  }