@rangojs/router 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/dist/types/browser/event-controller.d.ts +3 -3
  2. package/dist/types/browser/history-state.d.ts +2 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  4. package/dist/types/browser/partial-update.d.ts +16 -1
  5. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  6. package/dist/types/browser/react/Link.d.ts +19 -9
  7. package/dist/types/browser/react/context.d.ts +2 -2
  8. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  9. package/dist/types/browser/react/location-state.d.ts +1 -1
  10. package/dist/types/browser/transition-when.d.ts +80 -0
  11. package/dist/types/browser/types.d.ts +104 -9
  12. package/dist/types/build/generate-manifest.d.ts +8 -0
  13. package/dist/types/cache/background-task.d.ts +24 -2
  14. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +44 -13
  16. package/dist/types/cache/cache-policy.d.ts +1 -1
  17. package/dist/types/cache/cache-runtime.d.ts +7 -0
  18. package/dist/types/cache/cache-scope.d.ts +117 -35
  19. package/dist/types/cache/cache-tag.d.ts +24 -4
  20. package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
  21. package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
  22. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
  23. package/dist/types/cache/document-cache.d.ts +10 -0
  24. package/dist/types/cache/handle-snapshot.d.ts +40 -8
  25. package/dist/types/cache/invalidation-order.d.ts +23 -0
  26. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +88 -141
  28. package/dist/types/cache/tag-invalidation.d.ts +38 -14
  29. package/dist/types/cache/types.d.ts +68 -61
  30. package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
  31. package/dist/types/client-urls/navigation.d.ts +28 -2
  32. package/dist/types/client-urls/server-projection.d.ts +5 -2
  33. package/dist/types/client-urls/types.d.ts +5 -5
  34. package/dist/types/client.d.ts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.rsc.d.ts +1 -1
  37. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  38. package/dist/types/route-name.d.ts +7 -0
  39. package/dist/types/router/error-handling.d.ts +1 -1
  40. package/dist/types/router/intercept-resolution.d.ts +2 -0
  41. package/dist/types/router/match-context.d.ts +5 -0
  42. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  43. package/dist/types/router/router-options.d.ts +4 -1
  44. package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
  45. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  46. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
  47. package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
  48. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  49. package/dist/types/router/transition-when.d.ts +24 -10
  50. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  51. package/dist/types/rsc/full-payload.d.ts +14 -7
  52. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  53. package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
  54. package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
  55. package/dist/types/rsc/shell-capture.d.ts +78 -81
  56. package/dist/types/rsc/shell-serve.d.ts +19 -48
  57. package/dist/types/rsc/types.d.ts +2 -0
  58. package/dist/types/segment-system.d.ts +7 -0
  59. package/dist/types/server/context.d.ts +89 -61
  60. package/dist/types/server/cookie-store.d.ts +30 -18
  61. package/dist/types/server/handle-store.d.ts +52 -1
  62. package/dist/types/server/request-context.d.ts +66 -45
  63. package/dist/types/server/request-headers.d.ts +44 -0
  64. package/dist/types/server.d.ts +1 -0
  65. package/dist/types/testing/e2e/index.d.ts +1 -1
  66. package/dist/types/testing/index.d.ts +3 -2
  67. package/dist/types/testing/internal/action-id.d.ts +7 -0
  68. package/dist/types/testing/location-state-key.d.ts +24 -0
  69. package/dist/types/testing/render-route.d.ts +17 -1
  70. package/dist/types/testing/run-transition-when.d.ts +44 -69
  71. package/dist/types/testing/serve-shell-request.d.ts +7 -6
  72. package/dist/types/testing/shell-status.d.ts +34 -22
  73. package/dist/types/transition-when-ref.d.ts +79 -0
  74. package/dist/types/types/cache-types.d.ts +15 -3
  75. package/dist/types/types/index.d.ts +1 -1
  76. package/dist/types/types/segments.d.ts +86 -71
  77. package/dist/types/urls/path-helper-types.d.ts +3 -4
  78. package/dist/types/urls/pattern-types.d.ts +3 -4
  79. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  80. package/dist/vite/index.js +960 -97
  81. package/package.json +1 -1
  82. package/skills/cache-guide/SKILL.md +76 -49
  83. package/skills/caching/SKILL.md +167 -69
  84. package/skills/client-urls/SKILL.md +1 -1
  85. package/skills/comparison/references/framework-comparison.md +8 -5
  86. package/skills/document-cache/SKILL.md +24 -0
  87. package/skills/hooks/SKILL.md +3 -1
  88. package/skills/hooks/navigation.md +23 -2
  89. package/skills/hooks/state.md +98 -6
  90. package/skills/intercept/SKILL.md +29 -12
  91. package/skills/loader/SKILL.md +52 -21
  92. package/skills/middleware/SKILL.md +18 -0
  93. package/skills/migrate-nextjs/SKILL.md +3 -3
  94. package/skills/observability/SKILL.md +6 -5
  95. package/skills/parallel/SKILL.md +1 -1
  96. package/skills/ppr/SKILL.md +129 -77
  97. package/skills/prerender/SKILL.md +3 -3
  98. package/skills/rango/SKILL.md +25 -6
  99. package/skills/route/SKILL.md +2 -0
  100. package/skills/testing/SKILL.md +27 -25
  101. package/skills/testing/cache-prerender.md +9 -9
  102. package/skills/testing/client-components.md +45 -3
  103. package/skills/testing/loader.md +3 -2
  104. package/skills/testing/middleware.md +1 -0
  105. package/skills/testing/predicates.md +49 -49
  106. package/skills/testing/render-handler.md +1 -0
  107. package/skills/testing/serve-shell-request.md +14 -0
  108. package/skills/testing/server-actions.md +9 -3
  109. package/skills/theme/SKILL.md +8 -4
  110. package/skills/typesafety/env-and-bindings.md +8 -0
  111. package/skills/use-cache/SKILL.md +64 -19
  112. package/skills/view-transitions/SKILL.md +150 -29
  113. package/src/__augment-tests__/augmented.check.ts +23 -0
  114. package/src/browser/event-controller.ts +4 -4
  115. package/src/browser/history-state.ts +52 -1
  116. package/src/browser/link-interceptor.ts +4 -0
  117. package/src/browser/navigation-bridge.ts +77 -18
  118. package/src/browser/navigation-client.ts +4 -3
  119. package/src/browser/navigation-store.ts +43 -1
  120. package/src/browser/navigation-transaction.ts +31 -11
  121. package/src/browser/partial-update.ts +146 -35
  122. package/src/browser/prefetch/cache.ts +6 -6
  123. package/src/browser/prefetch/fetch.ts +2 -2
  124. package/src/browser/react/Link.tsx +38 -33
  125. package/src/browser/react/NavigationProvider.tsx +2 -2
  126. package/src/browser/react/context.ts +2 -2
  127. package/src/browser/react/location-state-shared.ts +241 -49
  128. package/src/browser/react/location-state.ts +5 -2
  129. package/src/browser/rsc-router.tsx +4 -0
  130. package/src/browser/server-action-bridge.ts +84 -15
  131. package/src/browser/transition-when.ts +205 -0
  132. package/src/browser/types.ts +124 -10
  133. package/src/build/generate-manifest.ts +77 -1
  134. package/src/cache/background-task.ts +55 -2
  135. package/src/cache/cache-exec-scope.ts +48 -1
  136. package/src/cache/cache-key-utils.ts +56 -16
  137. package/src/cache/cache-policy.ts +9 -4
  138. package/src/cache/cache-runtime.ts +197 -144
  139. package/src/cache/cache-scope.ts +304 -112
  140. package/src/cache/cache-tag.ts +105 -13
  141. package/src/cache/cf/cf-cache-store.ts +152 -76
  142. package/src/cache/cf/cf-shell-frame.ts +2 -2
  143. package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
  144. package/src/cache/document-cache.ts +53 -7
  145. package/src/cache/handle-snapshot.ts +52 -9
  146. package/src/cache/invalidation-order.ts +84 -0
  147. package/src/cache/request-tag-mask.ts +147 -0
  148. package/src/cache/shell-snapshot.ts +130 -348
  149. package/src/cache/tag-invalidation.ts +57 -51
  150. package/src/cache/types.ts +71 -67
  151. package/src/cache/vercel/vercel-cache-store.ts +61 -58
  152. package/src/client-urls/client-root.tsx +8 -4
  153. package/src/client-urls/client-urls.ts +8 -6
  154. package/src/client-urls/navigation.ts +55 -4
  155. package/src/client-urls/server-projection.ts +31 -10
  156. package/src/client-urls/types.ts +12 -5
  157. package/src/client.tsx +2 -0
  158. package/src/host/cookie-handler.ts +2 -1
  159. package/src/index.rsc.ts +3 -0
  160. package/src/index.ts +3 -0
  161. package/src/loader-redirect.tsx +7 -3
  162. package/src/prerender/build-shell-capture.ts +155 -146
  163. package/src/route-definition/dsl-helpers.ts +2 -0
  164. package/src/route-definition/helpers-types.ts +11 -11
  165. package/src/route-name.ts +12 -0
  166. package/src/router/content-negotiation.ts +4 -1
  167. package/src/router/error-handling.ts +5 -2
  168. package/src/router/handler-context.ts +10 -7
  169. package/src/router/intercept-resolution.ts +11 -1
  170. package/src/router/loader-resolution.ts +28 -3
  171. package/src/router/logging.ts +5 -3
  172. package/src/router/match-api.ts +62 -34
  173. package/src/router/match-context.ts +5 -0
  174. package/src/router/match-handlers.ts +9 -29
  175. package/src/router/match-middleware/background-revalidation.ts +3 -5
  176. package/src/router/match-middleware/cache-lookup.ts +35 -19
  177. package/src/router/match-middleware/cache-store.ts +15 -9
  178. package/src/router/match-result.ts +1 -0
  179. package/src/router/middleware.ts +2 -5
  180. package/src/router/navigation-snapshot.ts +6 -5
  181. package/src/router/prerender-match.ts +4 -0
  182. package/src/router/request-classification.ts +7 -4
  183. package/src/router/router-options.ts +4 -1
  184. package/src/router/segment-resolution/fresh.ts +0 -4
  185. package/src/router/segment-resolution/loader-cache.ts +150 -25
  186. package/src/router/segment-resolution/loader-mask.ts +3 -4
  187. package/src/router/segment-resolution/loader-snapshot.ts +15 -78
  188. package/src/router/segment-resolution/mask-nested.ts +47 -22
  189. package/src/router/segment-resolution/revalidation.ts +0 -3
  190. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  191. package/src/router/telemetry.ts +6 -3
  192. package/src/router/transition-when.ts +34 -52
  193. package/src/router.ts +2 -1
  194. package/src/rsc/attach-transition-when.ts +47 -0
  195. package/src/rsc/full-payload.ts +30 -10
  196. package/src/rsc/handler.ts +12 -10
  197. package/src/rsc/loader-fetch.ts +2 -1
  198. package/src/rsc/manifest-init.ts +4 -0
  199. package/src/rsc/origin-guard.ts +8 -5
  200. package/src/rsc/progressive-enhancement.ts +7 -30
  201. package/src/rsc/response-cache-serve.ts +68 -80
  202. package/src/rsc/response-route-handler.ts +2 -2
  203. package/src/rsc/rsc-rendering.ts +206 -252
  204. package/src/rsc/server-action.ts +8 -20
  205. package/src/rsc/shell-build-manifest.ts +22 -20
  206. package/src/rsc/shell-capture-constants.ts +136 -20
  207. package/src/rsc/shell-capture.ts +352 -589
  208. package/src/rsc/shell-serve.ts +73 -109
  209. package/src/rsc/ssr-setup.ts +3 -2
  210. package/src/rsc/types.ts +2 -0
  211. package/src/segment-system.tsx +40 -7
  212. package/src/server/context.ts +189 -124
  213. package/src/server/cookie-store.ts +153 -127
  214. package/src/server/handle-store.ts +245 -27
  215. package/src/server/request-context.ts +123 -84
  216. package/src/server/request-headers.ts +134 -0
  217. package/src/server.ts +4 -0
  218. package/src/testing/dispatch.ts +4 -7
  219. package/src/testing/e2e/index.ts +1 -0
  220. package/src/testing/index.ts +5 -0
  221. package/src/testing/internal/action-id.ts +25 -0
  222. package/src/testing/internal/context.ts +1 -1
  223. package/src/testing/location-state-key.ts +45 -0
  224. package/src/testing/render-handler.ts +1 -1
  225. package/src/testing/render-route.tsx +109 -16
  226. package/src/testing/run-client-revalidate.ts +3 -19
  227. package/src/testing/run-loader.ts +5 -1
  228. package/src/testing/run-transition-when.ts +109 -172
  229. package/src/testing/serve-shell-request.ts +10 -7
  230. package/src/testing/shell-status.ts +60 -56
  231. package/src/transition-when-ref.ts +220 -0
  232. package/src/types/cache-types.ts +15 -3
  233. package/src/types/index.ts +3 -0
  234. package/src/types/segments.ts +94 -91
  235. package/src/urls/path-helper-types.ts +3 -4
  236. package/src/urls/pattern-types.ts +3 -4
  237. package/src/vite/discovery/discover-routers.ts +3 -0
  238. package/src/vite/discovery/shell-prerender-phase.ts +0 -2
  239. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  240. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  241. package/src/vite/rango.ts +2 -0
  242. package/src/vite/router-discovery.ts +75 -5
  243. package/dist/types/rsc/transition-gate.d.ts +0 -23
  244. package/src/rsc/transition-gate.ts +0 -86
@@ -5,23 +5,20 @@
5
5
  * that must agree with them byte for byte. The handler layer's part of that
6
6
  * payload is the capture's own doc segment record: the capture writes it
7
7
  * first, renders its prelude from it, and every HIT replays it (no handler
8
- * runs on a HIT). What still executes on a HIT is the loader layer: live
9
- * loaders and promise-carrying bake-lane (`ssr: false`) loaders run, and the
10
- * values the bake-lane ones read must match what the capture baked (a
11
- * promise-free bake-lane loader is served from its pin and does not run). See docs/design/ppr-shell-resume.md.
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.
12
13
  *
13
- * The mechanism (Next.js resume-data-cache analog, adapted to Rango's cache
14
- * rings): the CAPTURE render records every cache-store read-hit and write it
15
- * performed (the {@link RecordingShellStore}); the records ride inside the
16
- * ShellCacheEntry as its `snapshot`; on a HIT the tail render reads through a
17
- * {@link SeededShellStore} overlay that serves those recorded values AS FRESH.
18
- * Everything NOT recorded (the holes — masked loaders were never executed at
19
- * capture, so their reads were never recorded) stays live.
20
- *
21
- * The invariant, verbatim: the snapshot is the doc record plus the cache-store
22
- * reads the capture's loaders performed; replaying them on a HIT reproduces
23
- * the shell content byte-identically; everything not recorded stays live.
24
- * {@link pruneShellSnapshot} drops the rest.
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).
25
22
  */
26
23
 
27
24
  import type {
@@ -32,74 +29,46 @@ import type {
32
29
  CachedEntryData,
33
30
  ShellCacheEntry,
34
31
  ShellSnapshotRecord,
35
- ShellSnapshotItemValue,
36
- ShellSnapshotResponseValue,
37
32
  ShellSnapshotLoaderValue,
38
33
  CacheReadError,
39
34
  } from "./types.js";
40
- import { CACHE_READ_ERROR } from "./types.js";
41
- import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
42
- import { isPerClientSignalHeader } from "../browser/cookie-name.js";
43
- import { isInsideAnyLoaderScope } from "../server/context.js";
44
-
45
- /** Compose the last-write-wins map key. NUL (`\u0000`) cannot appear in a cache key. */
46
- function recordKey(family: ShellSnapshotRecord["family"], key: string): string {
47
- return `${family}\u0000${key}`;
48
- }
49
-
50
- /** Serialize a Response to the snapshot's stored shape (base64 body). */
51
- async function serializeResponse(
52
- response: Response,
53
- ): Promise<ShellSnapshotResponseValue> {
54
- const body = await response.clone().arrayBuffer();
55
- const headers: [string, string][] = [];
56
- response.headers.forEach((value, name) => {
57
- // Mirror putResponse: per-client signal headers never enter a shared entry.
58
- if (isPerClientSignalHeader(name)) return;
59
- headers.push([name, value]);
60
- });
61
- return { status: response.status, headers, body: bufferToBase64(body) };
62
- }
35
+ import { settleGrowing } from "./background-task.js";
63
36
 
64
- /** Rebuild a live Response from a snapshot's stored response shape. */
65
- function deserializeResponse(value: ShellSnapshotResponseValue): Response {
66
- return new Response(base64ToBuffer(value.body), {
67
- status: value.status,
68
- headers: new Headers(value.headers),
69
- });
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;
70
50
  }
71
51
 
72
52
  /**
73
- * A store wrapper the CAPTURE render reads through. Every call passes through to
74
- * the underlying store unchanged; for the item/segment/response families it also
75
- * RECORDS, last-write-wins per (family, key):
76
- * - read-hits (get/getItem/getResponse returning non-null) — the value that
77
- * fed the shell,
78
- * - writes (set/setItem/putResponse) — the value a MISS computed and baked.
79
- * The shell family (getShell/putShell) is never recorded (the snapshot rides
80
- * inside a shell entry). Reads that MISS are not recorded (a miss produced no
81
- * 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.
82
59
  *
83
60
  * Deferred writes: cache writes run under waitUntil (fire-and-forget on Node,
84
- * executionContext on workerd), so their setItem/set calls — hence their records
85
- * — may land after the shell has quiesced. The capture collects those write
86
- * promises via {@link trackWrite} and awaits them ({@link settleWrites}) before
87
- * draining, so a MISS-at-capture value is still pinned.
88
- *
89
- * Loader attribution: every item/response access made inside a loader scope
90
- * (hit, miss or write) marks its key in {@link loaderKeys}. A loader re-runs
91
- * on every HIT, so those records stay pinned when the snapshot is pruned. A
92
- * miss counts too: a loader that joins a handler's in-flight "use cache" call
93
- * reads the store first (cache-runtime.ts) and finds nothing, and the record
94
- * then comes from the handler's write.
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.
95
65
  */
96
66
  export class RecordingShellStore<
97
67
  TEnv = unknown,
98
68
  > implements SegmentCacheStore<TEnv> {
99
- 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>();
100
71
  private readonly writes: Promise<unknown>[] = [];
101
- /** Record keys ({@link recordKey}) a loader read or wrote during the capture. */
102
- readonly loaderKeys: Set<string> = new Set();
103
72
 
104
73
  constructor(private readonly inner: SegmentCacheStore<TEnv>) {}
105
74
 
@@ -112,20 +81,8 @@ export class RecordingShellStore<
112
81
  get supportsPassiveShellReads(): true | undefined {
113
82
  return this.inner.supportsPassiveShellReads;
114
83
  }
115
-
116
- private record(
117
- family: ShellSnapshotRecord["family"],
118
- key: string,
119
- value: ShellSnapshotRecord["value"],
120
- ): void {
121
- this.records.set(recordKey(family, key), { family, key, value });
122
- }
123
-
124
- private noteLoaderAccess(
125
- family: ShellSnapshotRecord["family"],
126
- key: string,
127
- ): void {
128
- if (isInsideAnyLoaderScope()) this.loaderKeys.add(recordKey(family, key));
84
+ get isTagsInvalidatedSince(): SegmentCacheStore<TEnv>["isTagsInvalidatedSince"] {
85
+ return forwardTagHistory(this.inner);
129
86
  }
130
87
 
131
88
  /** Track a deferred cache-write promise so the capture can await it pre-drain. */
@@ -134,74 +91,50 @@ export class RecordingShellStore<
134
91
  }
135
92
 
136
93
  /**
137
- * Record a segment-family write into the snapshot WITHOUT touching the inner
138
- * store. The shell fast path's implicit doc-cache scope writes through this
139
- * (via {@link SnapshotOnlySegmentStore}): the recorded doc entry must ride
140
- * ONLY inside the shell entry — a passthrough write would leave a doc-keyed
141
- * entry in the real store that the NEXT capture's lookup would hit, replaying
142
- * 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
143
100
  * SWR recapture freshness).
144
101
  */
145
102
  recordSegmentWrite(key: string, data: CachedEntryData): void {
146
- this.record("segment", key, data);
103
+ this.records.set(key, data);
147
104
  }
148
105
 
149
106
  /**
150
- * Await the tracked deferred writes so their records are present before drain.
151
- * Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
152
- * cacheRoute path schedules its actual store.set in a second waitUntil while the
153
- * first is running), so each awaited batch may enqueue more. Loop until the
154
- * queue empties or the deadline passes. Bounded: a pathologically slow write
155
- * must never stall the capture task. Resolves true when every tracked write
156
- * settled, false at the deadline (settleCaptureRecord then fails the capture
157
- * when the doc record is among the writes still pending).
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.
158
114
  */
159
- async settleWrites(timeoutMs: number): Promise<boolean> {
160
- const deadline = Date.now() + timeoutMs;
161
- while (this.writes.length > 0) {
162
- const remaining = deadline - Date.now();
163
- if (remaining <= 0) return false;
164
- // Take the current batch; new writes scheduled while awaiting accumulate in
165
- // this.writes and are drained on the next iteration.
166
- const batch = this.writes.splice(0);
167
- let timer: ReturnType<typeof setTimeout> | undefined;
168
- const guard = new Promise<false>((resolve) => {
169
- timer = setTimeout(() => resolve(false), remaining);
170
- (timer as { unref?: () => void }).unref?.();
171
- });
172
- const settled = await Promise.race([
173
- Promise.allSettled(batch).then(() => true as const),
174
- guard,
175
- ]);
176
- if (timer) clearTimeout(timer);
177
- if (!settled) {
178
- // Keep tracking the batch: a later settleWrites still waits for it.
179
- this.writes.unshift(...batch);
180
- return false;
181
- }
182
- }
183
- return true;
115
+ settleWrites(timeoutMs: number): Promise<boolean> {
116
+ return settleGrowing(this.writes, Date.now() + timeoutMs);
184
117
  }
185
118
 
186
- /** The recorded snapshot (last-write-wins per family+key), or undefined if empty. */
119
+ /** The recorded segment records, or undefined if none. */
187
120
  drainSnapshot(): ShellSnapshotRecord[] | undefined {
188
- 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
+ );
189
129
  }
190
130
 
191
- /** One recorded value, by family and key. */
192
- getRecord(
193
- family: ShellSnapshotRecord["family"],
194
- key: string,
195
- ): ShellSnapshotRecord["value"] | undefined {
196
- return this.records.get(recordKey(family, key))?.value;
131
+ /** One recorded segment record, by key. */
132
+ getRecord(key: string): CachedEntryData | undefined {
133
+ return this.records.get(key);
197
134
  }
198
135
 
199
136
  async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
200
- const result = await this.inner.get(key);
201
- if (result && result !== CACHE_READ_ERROR) {
202
- this.record("segment", key, result.data);
203
- }
204
- return result;
137
+ return this.inner.get(key);
205
138
  }
206
139
 
207
140
  async set(
@@ -210,7 +143,6 @@ export class RecordingShellStore<
210
143
  ttl: number,
211
144
  swr?: number,
212
145
  ): Promise<void> {
213
- this.record("segment", key, data);
214
146
  return this.inner.set(key, data, ttl, swr);
215
147
  }
216
148
 
@@ -225,12 +157,7 @@ export class RecordingShellStore<
225
157
  async getResponse(
226
158
  key: string,
227
159
  ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
228
- if (!this.inner.getResponse) return null;
229
- this.noteLoaderAccess("response", key);
230
- const result = await this.inner.getResponse(key);
231
- if (result)
232
- this.record("response", key, await serializeResponse(result.response));
233
- return result;
160
+ return this.inner.getResponse ? this.inner.getResponse(key) : null;
234
161
  }
235
162
 
236
163
  async putResponse(
@@ -240,25 +167,11 @@ export class RecordingShellStore<
240
167
  swr?: number,
241
168
  tags?: string[],
242
169
  ): Promise<void> {
243
- if (!this.inner.putResponse) return;
244
- this.noteLoaderAccess("response", key);
245
- this.record("response", key, await serializeResponse(response));
246
- return this.inner.putResponse(key, response, ttl, swr, tags);
170
+ return this.inner.putResponse?.(key, response, ttl, swr, tags);
247
171
  }
248
172
 
249
173
  async getItem(key: string): Promise<CacheItemResult | null> {
250
- if (!this.inner.getItem) return null;
251
- this.noteLoaderAccess("item", key);
252
- const result = await this.inner.getItem(key);
253
- if (result) {
254
- const value: ShellSnapshotItemValue = {
255
- value: result.value,
256
- handles: result.handles,
257
- tags: result.tags,
258
- };
259
- this.record("item", key, value);
260
- }
261
- return result;
174
+ return this.inner.getItem ? this.inner.getItem(key) : null;
262
175
  }
263
176
 
264
177
  async setItem(
@@ -266,15 +179,7 @@ export class RecordingShellStore<
266
179
  value: string,
267
180
  options?: CacheItemOptions,
268
181
  ): Promise<void> {
269
- if (!this.inner.setItem) return;
270
- this.noteLoaderAccess("item", key);
271
- const stored: ShellSnapshotItemValue = {
272
- value,
273
- handles: options?.handles,
274
- tags: options?.tags,
275
- };
276
- this.record("item", key, stored);
277
- return this.inner.setItem(key, value, options);
182
+ return this.inner.setItem?.(key, value, options);
278
183
  }
279
184
 
280
185
  async getShell(
@@ -299,7 +204,7 @@ export class RecordingShellStore<
299
204
  }
300
205
  }
301
206
 
302
- /** True iff `store` is a RecordingShellStore (duck-typed across module copies). */
207
+ /** `store` when it is a RecordingShellStore (an instanceof check). */
303
208
  export function getRecordingStore<TEnv>(
304
209
  store: SegmentCacheStore<TEnv> | undefined,
305
210
  ): RecordingShellStore<TEnv> | undefined {
@@ -308,8 +213,8 @@ export function getRecordingStore<TEnv>(
308
213
 
309
214
  /**
310
215
  * True when `snapshot` carries the canonical doc segment record `docKey`
311
- * names, with at least one segment: the record partial replay and a
312
- * fast-path HIT tail consume.
216
+ * names, with at least one segment: the record partial replay and a document
217
+ * HIT tail consume.
313
218
  */
314
219
  export function hasDocRecord(
315
220
  snapshot: readonly ShellSnapshotRecord[] | undefined,
@@ -333,42 +238,24 @@ export function hasDocRecord(
333
238
  }
334
239
 
335
240
  /**
336
- * Who reads a stored snapshot besides the doc record
337
- * (docs/design/shell-entry-layout.md §2):
338
- * - "segments": nobody. A navigation-only entry is read only by partial
339
- * replay, which seeds the doc record alone (SeededShellStore
340
- * `segmentsOnly`, implicit doc scope only).
341
- * - "loaders": the loaders a document HIT re-runs. Every HIT tail replays
342
- * the handler layer from the doc record and never runs a handler, so the
343
- * records only handler code read are dead weight.
344
- */
345
- export type ShellSnapshotReaders = "segments" | "loaders";
346
-
347
- /**
348
- * Split a capture's snapshot into what its readers consume and the rest: the
349
- * doc record (`docKey`) always; with "loaders", the loader-family records and
350
- * the item/response records a loader touched during the capture
351
- * (RecordingShellStore.loaderKeys). Every other segment record goes: an
352
- * explicit cache() tier's reads and writes are keyed where no HIT tail and no
353
- * partial replay looks (both consult only the doc record). The doc record
354
- * stays a copy inside the entry: a reference to a separately stored record
355
- * could be evicted on its own, and the HIT would have nothing to replay.
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.
356
247
  */
357
248
  export function pruneShellSnapshot(
358
249
  snapshot: readonly ShellSnapshotRecord[],
359
- readers: ShellSnapshotReaders,
360
- loaderKeys: ReadonlySet<string>,
361
- docKey: string,
250
+ navigationOnly: boolean,
251
+ docKey: string | undefined,
362
252
  ): { kept: ShellSnapshotRecord[]; pruned: ShellSnapshotRecord[] } {
363
253
  const kept: ShellSnapshotRecord[] = [];
364
254
  const pruned: ShellSnapshotRecord[] = [];
365
255
  for (const record of snapshot) {
366
256
  const keep =
367
257
  (record.family === "segment" && record.key === docKey) ||
368
- (readers === "loaders" &&
369
- (record.family === "loader" ||
370
- (record.family !== "segment" &&
371
- loaderKeys.has(recordKey(record.family, record.key)))));
258
+ (record.family === "loader" && !navigationOnly);
372
259
  (keep ? kept : pruned).push(record);
373
260
  }
374
261
  return { kept, pruned };
@@ -419,7 +306,7 @@ export function estimateShellEntryBytes(parts: {
419
306
  );
420
307
  }
421
308
 
422
- /** Snapshot records by family, e.g. `segment:1/item:5` (no commas: it rides a Server-Timing desc). */
309
+ /** Snapshot records by family, e.g. `segment:1/loader:2` (no commas: it rides a Server-Timing desc). */
423
310
  export function countSnapshotFamilies(
424
311
  snapshot: readonly ShellSnapshotRecord[],
425
312
  ): string {
@@ -431,13 +318,13 @@ export function countSnapshotFamilies(
431
318
  }
432
319
 
433
320
  /**
434
- * The store the shell fast path's IMPLICIT doc-cache scope resolves during a
435
- * capture: reads pass through the recording store (a real-store hit is
436
- * recorded, exactly like any capture read), but segment WRITES are recorded
437
- * into the snapshot only — see {@link RecordingShellStore.recordSegmentWrite}
438
- * for why passthrough would break SWR recapture. Routes with their OWN
439
- * cache() config never see this store (their scope resolves the app-level
440
- * recording store and keeps today's record-and-write behavior).
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.
441
328
  */
442
329
  export class SnapshotOnlySegmentStore<
443
330
  TEnv = unknown,
@@ -464,28 +351,14 @@ export class SnapshotOnlySegmentStore<
464
351
  }
465
352
  }
466
353
 
467
- /**
468
- * Materialize the loader-family seed from a shell snapshot for a HIT's tail
469
- * render: Flight-deserialize each recorded (promise-elided) bake-lane
470
- * container into a segment-key -> container Map, which serveShellHit assigns
471
- * to the tail context's `_shellLoaderSeed` for the resolveLoaderData overlay.
472
- * Lives here so every snapshot family is decoded in this module (the
473
- * item/segment/response families via {@link SeededShellStore}); the loader
474
- * family is not a store read, so it seeds the context instead of a store.
475
- *
476
- * Deserializations run in parallel; a record that fails to decode is skipped
477
- * (that loader drifts — the pre-snapshot behavior — instead of failing the
478
- * HIT). Returns undefined when the snapshot carries no loader records, without
479
- * touching the Flight codec (kept lazy for cold paths and non-RSC configs).
480
- */
354
+ /** One decoded loader-family pin (buildShellLoaderSeed). */
481
355
  export interface ShellLoaderSeedEntry {
482
356
  /** The Flight-deserialized elided container (recorded paths + markers). */
483
357
  container: unknown;
484
358
  /**
485
- * True when the record carries hole markers (or predates the capture-side
486
- * hole bit, where hole-ness is unknown): the HIT overlay must gate on the
487
- * fresh run. False = fully pinned; the payload promise resolves immediately
488
- * from the pin (loader-cache.ts pin-first path).
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).
489
362
  */
490
363
  holes: boolean;
491
364
  /**
@@ -496,6 +369,20 @@ export interface ShellLoaderSeedEntry {
496
369
  runs: boolean;
497
370
  }
498
371
 
372
+ /**
373
+ * Materialize the loader-family seed from a shell snapshot for a HIT's tail
374
+ * render: Flight-deserialize each recorded (promise-elided) bake-lane
375
+ * container into a segment-key -> container Map, which serveShellHit assigns
376
+ * to the tail context's `_shellLoaderSeed` for the resolveLoaderData overlay.
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.
380
+ *
381
+ * Deserializations run in parallel; a record that fails to decode is skipped
382
+ * (that loader drifts — the pre-snapshot behavior — instead of failing the
383
+ * HIT). Returns undefined when the snapshot carries no loader records, without
384
+ * touching the Flight codec (kept lazy for cold paths and non-RSC configs).
385
+ */
499
386
  export async function buildShellLoaderSeed(
500
387
  snapshot: ShellSnapshotRecord[],
501
388
  ): Promise<Map<string, ShellLoaderSeedEntry> | undefined> {
@@ -515,10 +402,9 @@ export async function buildShellLoaderSeed(
515
402
  rec.key,
516
403
  {
517
404
  container: await deserializeResult(stored.value),
518
- // A record without a bit predates it: without the hole bit it
519
- // reads as hole-carrying (unknown hole-ness keeps the gated
520
- // path), and without the runs bit as runs (its snapshot lacks
521
- // the loader-owned pushes, so the body supplies them).
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.
522
408
  holes: stored.holes !== 0,
523
409
  runs: stored.runs !== 0,
524
410
  },
@@ -537,50 +423,29 @@ export async function buildShellLoaderSeed(
537
423
  }
538
424
 
539
425
  /**
540
- * A read-through overlay the HIT tail render reads through. For a key present in
541
- * the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
542
- * a pinned key must NOT kick SWR background revalidation) so the tail's payload
543
- * matches the frozen prelude. Every other read falls through to the real store
544
- * (the holes — masked loaders were never recorded — stay live). Writes pass
545
- * through so a live hole may legitimately write, except that `segmentsOnly`
546
- * fully isolates the segment family: misses do not fall through, and writes or
547
- * deletes stay local to the navigation overlay. The shell family always passes
548
- * through. With `segmentsOnly`, item and response reads also pass through so a
549
- * partial navigation cannot freeze captured data or write a partial result into
550
- * 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.
551
435
  */
552
436
  export class SeededShellStore<
553
437
  TEnv = unknown,
554
438
  > implements SegmentCacheStore<TEnv> {
555
- private readonly items: Map<string, ShellSnapshotItemValue> | undefined;
556
439
  private readonly segments = new Map<string, CachedEntryData>();
557
- private readonly responses:
558
- | Map<string, ShellSnapshotResponseValue>
559
- | undefined;
560
- private readonly segmentsOnly: boolean;
561
440
 
562
441
  constructor(
563
442
  private readonly inner: SegmentCacheStore<TEnv>,
564
- snapshot: ShellSnapshotRecord[],
565
- options?: { segmentsOnly?: boolean },
443
+ snapshot: readonly ShellSnapshotRecord[],
566
444
  ) {
567
- this.segmentsOnly = options?.segmentsOnly === true;
568
- this.items = this.segmentsOnly ? undefined : new Map();
569
- this.responses = this.segmentsOnly ? undefined : new Map();
570
445
  for (const rec of snapshot) {
571
- if (!rec || typeof rec !== "object") continue;
572
- if (rec.family === "segment") {
446
+ if (rec && typeof rec === "object" && rec.family === "segment") {
573
447
  this.segments.set(rec.key, rec.value as CachedEntryData);
574
- } else if (this.segmentsOnly) {
575
- continue;
576
- } else if (rec.family === "item") {
577
- this.items?.set(rec.key, rec.value as ShellSnapshotItemValue);
578
- } else if (rec.family === "response") {
579
- this.responses?.set(rec.key, rec.value as ShellSnapshotResponseValue);
580
448
  }
581
- // "loader" family records are not store reads — serveShellHit seeds them
582
- // onto the tail context (_shellLoaderSeed) for the resolveLoaderData
583
- // overlay instead.
584
449
  }
585
450
  }
586
451
 
@@ -590,101 +455,18 @@ export class SeededShellStore<
590
455
  get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
591
456
  return this.inner.keyGenerator;
592
457
  }
593
- get supportsPassiveShellReads(): true | undefined {
594
- return this.inner.supportsPassiveShellReads;
595
- }
596
458
 
597
459
  async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
598
460
  const seeded = this.segments.get(key);
599
- if (seeded) return { data: seeded, shouldRevalidate: false };
600
- if (this.segmentsOnly) return null;
601
- return this.inner.get(key);
461
+ return seeded ? { data: seeded, shouldRevalidate: false } : null;
602
462
  }
603
463
 
604
- async set(
605
- key: string,
606
- data: CachedEntryData,
607
- ttl: number,
608
- swr?: number,
609
- ): Promise<void> {
610
- if (this.segmentsOnly) {
611
- this.segments.set(key, data);
612
- return;
613
- }
614
- return this.inner.set(key, data, ttl, swr);
464
+ async set(key: string, data: CachedEntryData): Promise<void> {
465
+ this.segments.set(key, data);
615
466
  }
616
467
 
617
468
  async delete(key: string): Promise<boolean> {
618
- if (this.segmentsOnly) {
619
- return this.segments.delete(key);
620
- }
621
- return this.inner.delete(key);
622
- }
623
-
624
- async clear(): Promise<void> {
625
- return this.inner.clear?.();
626
- }
627
-
628
- async getResponse(
629
- key: string,
630
- ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
631
- const seeded = this.responses?.get(key);
632
- if (seeded) {
633
- return { response: deserializeResponse(seeded), shouldRevalidate: false };
634
- }
635
- return this.inner.getResponse ? this.inner.getResponse(key) : null;
636
- }
637
-
638
- async putResponse(
639
- key: string,
640
- response: Response,
641
- ttl: number,
642
- swr?: number,
643
- tags?: string[],
644
- ): Promise<void> {
645
- return this.inner.putResponse?.(key, response, ttl, swr, tags);
646
- }
647
-
648
- async getItem(key: string): Promise<CacheItemResult | null> {
649
- const seeded = this.items?.get(key);
650
- if (seeded) {
651
- return {
652
- value: seeded.value,
653
- handles: seeded.handles,
654
- tags: seeded.tags,
655
- shouldRevalidate: false,
656
- };
657
- }
658
- return this.inner.getItem ? this.inner.getItem(key) : null;
659
- }
660
-
661
- async setItem(
662
- key: string,
663
- value: string,
664
- options?: CacheItemOptions,
665
- ): Promise<void> {
666
- return this.inner.setItem?.(key, value, options);
667
- }
668
-
669
- async getShell(
670
- key: string,
671
- options?: { claimRevalidation?: boolean },
672
- ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
673
- return this.inner.getShell ? this.inner.getShell(key, options) : null;
674
- }
675
-
676
- async putShell(
677
- key: string,
678
- entry: ShellCacheEntry,
679
- ttlSeconds?: number,
680
- swrSeconds?: number,
681
- tags?: string[],
682
- ): Promise<"stored" | "invalidated" | "uncacheable" | void> {
683
- return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
684
- }
685
-
686
- async invalidateTags(tags: string[]): Promise<void> {
687
- return this.inner.invalidateTags?.(tags);
469
+ return this.segments.delete(key);
688
470
  }
689
471
  }
690
472