@rangojs/router 0.26.6 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-coordinator.d.ts +0 -4
  4. package/dist/types/browser/action-fence.d.ts +11 -3
  5. package/dist/types/browser/event-controller.d.ts +20 -1
  6. package/dist/types/browser/history-state.d.ts +7 -9
  7. package/dist/types/browser/intercept-utils.d.ts +23 -0
  8. package/dist/types/browser/navigation-store.d.ts +8 -6
  9. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  10. package/dist/types/browser/rsc-router.d.ts +1 -1
  11. package/dist/types/browser/segment-reconciler.d.ts +6 -4
  12. package/dist/types/browser/types.d.ts +2 -1
  13. package/dist/types/browser/wire-protocol.d.ts +25 -0
  14. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  15. package/dist/types/cache/cache-scope.d.ts +34 -22
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  18. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  19. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  20. package/dist/types/cache/segment-codec.d.ts +14 -0
  21. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  22. package/dist/types/cache/tag-invalidation.d.ts +10 -0
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +4 -6
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +4 -4
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +12 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +10 -20
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +35 -87
  38. package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
  39. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/revalidation.d.ts +6 -1
  44. package/dist/types/router/router-context.d.ts +9 -63
  45. package/dist/types/router/router-interfaces.d.ts +8 -14
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +93 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution.d.ts +1 -2
  52. package/dist/types/router/segment-wrappers.d.ts +11 -48
  53. package/dist/types/router/telemetry.d.ts +2 -0
  54. package/dist/types/router/types.d.ts +10 -0
  55. package/dist/types/rsc/full-payload.d.ts +2 -2
  56. package/dist/types/rsc/handler-context.d.ts +6 -6
  57. package/dist/types/rsc/json-route-result.d.ts +5 -9
  58. package/dist/types/rsc/request-stages.d.ts +203 -0
  59. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  60. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  61. package/dist/types/rsc/routine-plan.d.ts +1 -1
  62. package/dist/types/rsc/server-action.d.ts +2 -2
  63. package/dist/types/rsc/shell-capture-constants.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture.d.ts +132 -15
  65. package/dist/types/rsc/shell-serve.d.ts +37 -12
  66. package/dist/types/server/context.d.ts +9 -8
  67. package/dist/types/server/handle-store.d.ts +16 -0
  68. package/dist/types/server/render-pass.d.ts +72 -0
  69. package/dist/types/server/request-context.d.ts +24 -104
  70. package/dist/types/server/rewrite-trail.d.ts +2 -2
  71. package/dist/types/testing/dispatch.d.ts +35 -18
  72. package/dist/types/testing/serve-shell-request.d.ts +7 -0
  73. package/dist/types/types/cache-types.d.ts +6 -3
  74. package/dist/types/types/error-types.d.ts +9 -1
  75. package/dist/types/types/handler-context.d.ts +18 -7
  76. package/dist/types/types/segments.d.ts +1 -1
  77. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  78. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  79. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  80. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  81. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  82. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  83. package/dist/types/vite/discovery/state.d.ts +12 -0
  84. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  85. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  86. package/dist/types/vite/router-discovery.d.ts +8 -47
  87. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  88. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  89. package/dist/types/wire-params.d.ts +34 -0
  90. package/dist/vite/index.js +3809 -3710
  91. package/package.json +1 -1
  92. package/skills/cache-guide/SKILL.md +8 -5
  93. package/skills/caching/SKILL.md +37 -18
  94. package/skills/client-urls/SKILL.md +2 -1
  95. package/skills/document-cache/SKILL.md +18 -1
  96. package/skills/handler-use/SKILL.md +1 -1
  97. package/skills/intercept/SKILL.md +53 -13
  98. package/skills/layout/SKILL.md +11 -7
  99. package/skills/loader/SKILL.md +30 -13
  100. package/skills/middleware/SKILL.md +19 -1
  101. package/skills/migrate-nextjs/SKILL.md +5 -4
  102. package/skills/parallel/SKILL.md +5 -4
  103. package/skills/ppr/SKILL.md +19 -3
  104. package/skills/prerender/SKILL.md +39 -15
  105. package/skills/rango/SKILL.md +23 -11
  106. package/skills/server-actions/SKILL.md +51 -6
  107. package/skills/testing/predicates.md +1 -1
  108. package/skills/testing/response-routes.md +6 -4
  109. package/skills/testing/serve-shell-request.md +7 -7
  110. package/skills/use-cache/SKILL.md +46 -8
  111. package/skills/ux-architecture/SKILL.md +3 -2
  112. package/src/__internal.ts +7 -1
  113. package/src/browser/action-coordinator.ts +2 -13
  114. package/src/browser/action-fence.ts +11 -3
  115. package/src/browser/connection-warmup.ts +3 -1
  116. package/src/browser/event-controller.ts +42 -3
  117. package/src/browser/history-state.ts +15 -13
  118. package/src/browser/intercept-utils.ts +51 -0
  119. package/src/browser/navigation-bridge.ts +34 -30
  120. package/src/browser/navigation-client.ts +21 -33
  121. package/src/browser/navigation-store.ts +25 -27
  122. package/src/browser/navigation-transaction.ts +7 -11
  123. package/src/browser/partial-update.ts +8 -0
  124. package/src/browser/prefetch/cache.ts +2 -1
  125. package/src/browser/prefetch/fetch.ts +8 -16
  126. package/src/browser/prefetch/queue.ts +4 -4
  127. package/src/browser/react/NavigationProvider.tsx +2 -1
  128. package/src/browser/react/filter-segment-order.ts +2 -2
  129. package/src/browser/rsc-router.tsx +17 -2
  130. package/src/browser/segment-reconciler.ts +66 -53
  131. package/src/browser/server-action-bridge.ts +265 -127
  132. package/src/browser/types.ts +2 -1
  133. package/src/browser/wire-protocol.ts +41 -0
  134. package/src/cache/cache-key-utils.ts +11 -5
  135. package/src/cache/cache-runtime.ts +110 -50
  136. package/src/cache/cache-scope.ts +110 -64
  137. package/src/cache/cf/cf-cache-constants.ts +2 -2
  138. package/src/cache/cf/cf-cache-store.ts +486 -874
  139. package/src/cache/cf/cf-kv-utils.ts +1 -1
  140. package/src/cache/document-cache.ts +42 -15
  141. package/src/cache/handle-snapshot.ts +19 -7
  142. package/src/cache/segment-codec.ts +55 -0
  143. package/src/cache/shell-snapshot.ts +2 -2
  144. package/src/cache/tag-invalidation.ts +21 -2
  145. package/src/cache/taint.ts +5 -3
  146. package/src/cache/types.ts +7 -3
  147. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  148. package/src/handles/pushed-by.ts +1 -1
  149. package/src/prefetch-markers.ts +4 -7
  150. package/src/prerender/store.ts +76 -0
  151. package/src/prerender/warm-request.ts +8 -4
  152. package/src/prerender.ts +2 -2
  153. package/src/route-name.ts +8 -0
  154. package/src/router/error-handling.ts +4 -4
  155. package/src/router/instrument.ts +4 -3
  156. package/src/router/intercept-resolution.ts +77 -75
  157. package/src/router/loader-resolution.ts +5 -4
  158. package/src/router/match-api.ts +4 -5
  159. package/src/router/match-context.ts +33 -12
  160. package/src/router/match-handlers.ts +8 -20
  161. package/src/router/match-middleware/background-revalidation.ts +41 -53
  162. package/src/router/match-middleware/cache-lookup.ts +680 -720
  163. package/src/router/match-middleware/cache-store.ts +14 -12
  164. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  165. package/src/router/match-middleware/segment-resolution.ts +34 -61
  166. package/src/router/match-result.ts +1 -20
  167. package/src/router/middleware.ts +69 -11
  168. package/src/router/navigation-snapshot.ts +4 -3
  169. package/src/router/prerender-match.ts +100 -44
  170. package/src/router/preview-match.ts +6 -41
  171. package/src/router/request-classification.ts +13 -7
  172. package/src/router/revalidation.ts +33 -7
  173. package/src/router/route-snapshot.ts +2 -5
  174. package/src/router/router-context.ts +14 -117
  175. package/src/router/router-interfaces.ts +8 -14
  176. package/src/router/segment-resolution/loader-cache.ts +14 -9
  177. package/src/router/segment-resolution/loader-mask.ts +5 -5
  178. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  179. package/src/router/segment-resolution/resolution-pass.ts +137 -0
  180. package/src/router/segment-resolution/revalidation.ts +897 -776
  181. package/src/router/segment-resolution.ts +4 -13
  182. package/src/router/segment-wrappers.ts +51 -269
  183. package/src/router/telemetry.ts +5 -0
  184. package/src/router/types.ts +10 -0
  185. package/src/router.ts +12 -13
  186. package/src/rsc/full-payload.ts +6 -5
  187. package/src/rsc/handler-context.ts +8 -14
  188. package/src/rsc/handler.ts +126 -507
  189. package/src/rsc/json-route-result.ts +5 -9
  190. package/src/rsc/loader-fetch.ts +6 -5
  191. package/src/rsc/progressive-enhancement.ts +5 -0
  192. package/src/rsc/request-stages.ts +757 -0
  193. package/src/rsc/response-cache-serve.ts +10 -14
  194. package/src/rsc/response-route-handler.ts +13 -5
  195. package/src/rsc/rewrite-passes.ts +2 -1
  196. package/src/rsc/routine-plan.ts +1 -1
  197. package/src/rsc/rsc-rendering.ts +172 -349
  198. package/src/rsc/server-action.ts +87 -113
  199. package/src/rsc/shell-capture-constants.ts +3 -1
  200. package/src/rsc/shell-capture.ts +212 -169
  201. package/src/rsc/shell-serve.ts +121 -18
  202. package/src/rsc/ssr-setup.ts +5 -4
  203. package/src/segment-system.tsx +3 -3
  204. package/src/server/context.ts +22 -25
  205. package/src/server/handle-store.ts +55 -0
  206. package/src/server/render-pass.ts +291 -0
  207. package/src/server/request-context.ts +26 -115
  208. package/src/server/rewrite-trail.ts +6 -4
  209. package/src/ssr/index.tsx +1 -1
  210. package/src/ssr/ssr-root.tsx +1 -0
  211. package/src/testing/dispatch.ts +223 -710
  212. package/src/testing/e2e/page-helpers.ts +2 -2
  213. package/src/testing/serve-shell-request.ts +37 -32
  214. package/src/types/cache-types.ts +6 -3
  215. package/src/types/error-types.ts +9 -1
  216. package/src/types/handler-context.ts +21 -7
  217. package/src/types/segments.ts +1 -1
  218. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  219. package/src/vite/discovery/build-env.ts +148 -0
  220. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  221. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  222. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  223. package/src/vite/discovery/discover-routers.ts +1 -1
  224. package/src/vite/discovery/discovery-errors.ts +1 -1
  225. package/src/vite/discovery/prerender-collection.ts +24 -39
  226. package/src/vite/discovery/state.ts +15 -0
  227. package/src/vite/plugins/version-plugin.ts +1 -1
  228. package/src/vite/plugins/virtual-entries.ts +1 -1
  229. package/src/vite/router-discovery.ts +29 -2428
  230. package/src/vite/temp-rsc-server.ts +223 -0
  231. package/src/vite/utils/prerender-utils.ts +47 -0
  232. package/src/wire-params.ts +35 -0
  233. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  234. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -1,100 +1,55 @@
1
1
  /**
2
2
  * Cache Lookup Middleware
3
3
  *
4
- * First middleware in the pipeline. Checks cache before segment resolution.
4
+ * First middleware in the pipeline. Before segment resolution runs, it serves
5
+ * a stored match from the first source that answers:
5
6
  *
6
- * FLOW DIAGRAM
7
- * ============
8
- *
9
- * source (empty)
10
- * |
11
- * v
12
- * +---------------------+
13
- * | Is action request? |──yes──> yield* source (pass through)
14
- * +---------------------+
15
- * | no
16
- * v
17
- * +---------------------+
18
- * | Cache enabled? |──no───> yield* source (pass through)
19
- * +---------------------+
20
- * | yes
21
- * v
22
- * +---------------------+
23
- * | Lookup cache |
24
- * | (pathname, params) |
25
- * +---------------------+
26
- * |
27
- * +-----+-----+
28
- * | |
29
- * miss hit
30
- * | |
31
- * v v
32
- * yield* Set state.cacheHit = true
33
- * source Set state.shouldRevalidate
34
- * | |
35
- * | v
36
- * | +---------------------------+
37
- * | | For each cached segment: |
38
- * | | - Apply revalidation |
39
- * | | - Set component = null |
40
- * | | if client has it |
41
- * | +---------------------------+
42
- * | |
43
- * | v
44
- * | +---------------------------+
45
- * | | Resolve fresh loaders | <-- Loaders are NEVER cached
46
- * | | (always fresh data) |
47
- * | +---------------------------+
48
- * | |
49
- * | v
50
- * | yield cached segments
51
- * | yield fresh loader segments
52
- * | |
53
- * +-----------+
54
- * |
55
- * v
56
- * next middleware
7
+ * 1. Prerender: the writable on-demand overlay, then the bundled build
8
+ * manifest. A live render (an action or a stale partial) reads them too
9
+ * when the route has no live handler (not a Passthrough).
10
+ * 2. The dev Static store (`__PRERENDER_DEV_URL`, isStaticPrerender entries).
11
+ * 3. The route's cache() record (the segment cache). An action or a stale
12
+ * partial renders live (rendersLive) and reads no record.
57
13
  *
14
+ * Every source serves through serveStoredSegments: pre-rendering is caching
15
+ * at build time, so a prerender entry is a cache hit through the same segment
16
+ * path. When no source answers, the request passes through to
17
+ * segment-resolution.
58
18
  *
59
- * CACHE BEHAVIOR
60
- * ==============
19
+ * A STORED HIT
20
+ * ============
61
21
  *
62
- * Cache HIT:
63
22
  * - state.cacheHit = true signals downstream middleware to skip
64
- * - Cached segments have their components nullified if client already has them
65
- * - Loaders are always re-resolved for fresh data
66
- * - state.shouldRevalidate triggers background SWR if cache was stale
67
- *
68
- * Cache MISS:
69
- * - Passes through to segment-resolution middleware
70
- * - No segments yielded from this middleware
71
- *
72
- * Loaders:
73
- * - NEVER cached in the segment cache
74
- * - Always resolved fresh on every request
75
- * - Ensures data freshness even with cached UI components
76
- * - Segment cache staleness does NOT propagate to loader revalidation;
23
+ * - A segment the client already holds and keeps has its component nullified
24
+ * - Loaders are always re-resolved fresh: never cached or pre-rendered.
25
+ * Segment cache staleness does NOT propagate to loader revalidation;
77
26
  * loaders use their own revalidation rules (actionId, user-defined)
27
+ * - A cache() record sets state.shouldRevalidate, which triggers background
28
+ * SWR when the record was stale
78
29
  *
30
+ * REVALIDATION RULES (cache() record)
31
+ * ===================================
79
32
  *
80
- * REVALIDATION RULES
81
- * ==================
33
+ * Each record segment the client holds is evaluated against its rules:
82
34
  *
83
- * Each cached segment is evaluated against its revalidation rules:
84
- *
85
- * 1. No rules defined -> use default (skip if client has segment)
35
+ * 1. No rules defined -> re-render the route's own segments when the search
36
+ * or the route params changed; otherwise skip (nullify component)
86
37
  * 2. Rules return false -> skip re-render (nullify component)
87
38
  * 3. Rules return true -> re-render (keep component)
88
39
  *
89
- * Revalidation context includes:
90
- * - Previous/next URL and params
91
- * - Request object
92
- * - Action context (if POST)
40
+ * Revalidation context includes the previous/next URL and params, the request
41
+ * and the action context (if POST).
93
42
  */
94
43
  import type { InternalHandlerContext, ResolvedSegment } from "../../types.js";
95
44
  import { isPprEntry, type EntryData } from "../../server/context.js";
96
- import type { MatchContext, MatchPipelineState } from "../match-context.js";
45
+ import {
46
+ rendersLive,
47
+ resolutionPassInput,
48
+ type MatchContext,
49
+ type MatchPipelineState,
50
+ } from "../match-context.js";
97
51
  import { getRouterContext, type RouterContext } from "../router-context.js";
52
+ import { freshPassInput } from "../segment-resolution/resolution-pass.js";
98
53
  import { observeEvent } from "../instrument.js";
99
54
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
100
55
  import { treeHasStreaming } from "./segment-resolution.js";
@@ -102,6 +57,7 @@ import { loaderPins } from "../segment-resolution/loader-cache.js";
102
57
  import {
103
58
  fillSends,
104
59
  interceptFillSkips,
60
+ isFillRequest,
105
61
  recordUse,
106
62
  } from "../segment-resolution/prefetch-deferral.js";
107
63
  import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
@@ -129,29 +85,21 @@ import { prerenderStoreShortCircuits } from "../navigation-snapshot.js";
129
85
  import { paramsEqual } from "../params-util.js";
130
86
  import { requestHeaders } from "../../server/request-headers.js";
131
87
  import { isInterceptSegment } from "../../browser/intercept-utils.js";
88
+ import {
89
+ interceptRecordKey,
90
+ interceptSlotId,
91
+ } from "../intercept-resolution.js";
132
92
 
133
93
  // Lazily initialized prerender store singleton and dynamically imported deps.
134
94
  // Dynamic imports prevent pulling in @vitejs/plugin-rsc/rsc virtual module at
135
95
  // top-level, which breaks vitest (only URLs with file:, data:, node: schemes).
136
96
  let prerenderStoreInstance: PrerenderStore | null | undefined;
137
- let _deserializeSegments:
138
- | typeof import("../../cache/segment-codec.js").deserializeSegments
139
- | undefined;
140
- let _fragmentSegments:
141
- | typeof import("../../cache/segment-codec.js").fragmentSegments
142
- | undefined;
143
- let _restoreHandles:
144
- | typeof import("../../cache/handle-snapshot.js").restoreHandles
145
- | undefined;
146
- let _decodeHandles:
147
- | typeof import("../../cache/handle-snapshot.js").decodeHandles
148
- | undefined;
149
- let _restoreRecordHandles:
150
- | typeof import("../../cache/handle-snapshot.js").restoreRecordHandles
151
- | undefined;
97
+ let codec: typeof import("../../cache/segment-codec.js") | undefined;
98
+ let handleSnapshot: typeof import("../../cache/handle-snapshot.js") | undefined;
152
99
  let _hashParams:
153
100
  | typeof import("../../prerender/param-hash.js").hashParams
154
101
  | undefined;
102
+ let storeModule: typeof import("../../prerender/store.js") | undefined;
155
103
 
156
104
  /**
157
105
  * @internal Replace the prerender store this module serves from, and return
@@ -167,213 +115,538 @@ export function setPrerenderStoreForTests(
167
115
  return previous;
168
116
  }
169
117
 
170
- async function ensurePrerenderDeps() {
171
- if (!_deserializeSegments) {
172
- const [codec, snapshot, paramHash, store] = await Promise.all([
173
- import("../../cache/segment-codec.js"),
174
- import("../../cache/handle-snapshot.js"),
175
- import("../../prerender/param-hash.js"),
176
- import("../../prerender/store.js"),
177
- ]);
178
- _deserializeSegments = codec.deserializeSegments;
179
- _fragmentSegments = codec.fragmentSegments;
180
- _restoreHandles = snapshot.restoreHandles;
181
- _decodeHandles = snapshot.decodeHandles;
182
- _restoreRecordHandles = snapshot.restoreRecordHandles;
183
- _hashParams = paramHash.hashParams;
184
- if (prerenderStoreInstance === undefined) {
185
- prerenderStoreInstance = store.createPrerenderStore();
186
- }
118
+ async function ensurePrerenderDeps(): Promise<void> {
119
+ if (codec) return;
120
+ const [segmentCodec, snapshot, paramHash, store] = await Promise.all([
121
+ import("../../cache/segment-codec.js"),
122
+ import("../../cache/handle-snapshot.js"),
123
+ import("../../prerender/param-hash.js"),
124
+ import("../../prerender/store.js"),
125
+ ]);
126
+ codec = segmentCodec;
127
+ handleSnapshot = snapshot;
128
+ _hashParams = paramHash.hashParams;
129
+ storeModule = store;
130
+ if (prerenderStoreInstance === undefined) {
131
+ prerenderStoreInstance = store.createPrerenderStore();
187
132
  }
188
133
  }
189
134
 
190
135
  /**
191
- * Shared yield logic for prerender and static handler store entries.
192
- * Deserializes segments, replays handle data, yields segments with partial
193
- * navigation nullification, and resolves fresh loaders.
136
+ * Where a stored match is read from. Each source serves through
137
+ * serveStoredSegments: a prerender entry is a cache hit through the same
138
+ * segment path (prerender-api-design.md, "Core Principle").
139
+ *
140
+ * - `segment-cache`: a cache() record, decoded by the lookup, which replays
141
+ * its handles on a document request; on a partial one `restoreHandles`
142
+ * replays those of the segments it sends. `scope` is the scope whose
143
+ * record answered.
144
+ * - `prerender-build`: the bundled build manifest (immutable).
145
+ * - `prerender-overlay`: the writable on-demand overlay. Its entry may be
146
+ * newer than the client's copy: one that a refresh (possibly inside this
147
+ * very action) replaced.
194
148
  */
195
- // Resolve loaders fresh on a cache hit (loaders are NEVER cached) and yield
196
- // the loader segments, updating state.matchedIds and pushing the loader-resolve
197
- // + cache-hit metrics. Shared verbatim by the prerender-store path
198
- // (yieldFromStore) and the runtime cache-hit path (withCacheLookup). The router
199
- // resolve fns are passed in (captured early by the callers) rather than re-read
200
- // from getRouterContext() here, because that is ALS-backed and the callers run
201
- // awaits before reaching this point (workerd can disrupt ALS mid-pipeline).
202
- async function* resolveFreshLoadersAndYield<TEnv>(
203
- ctx: MatchContext<TEnv>,
204
- entries: EntryData[],
205
- state: MatchPipelineState,
206
- pipelineStart: number,
207
- ms: MatchContext<TEnv>["metricsStore"],
208
- resolveLoadersOnly: RouterContext<TEnv>["resolveLoadersOnly"],
209
- resolveLoadersOnlyWithRevalidation: RouterContext<TEnv>["resolveLoadersOnlyWithRevalidation"],
210
- ): AsyncGenerator<ResolvedSegment> {
211
- const loaderStart = performance.now();
212
- entries = entries.filter((e) => !interceptFillSkips(ctx, e.shortCode));
213
-
214
- if (ctx.isFullMatch) {
215
- if (resolveLoadersOnly) {
216
- const loaderSegments = await ctx.Store.run(() =>
217
- resolveLoadersOnly(entries, ctx.handlerContext),
218
- );
219
- state.matchedIds = state.cachedMatchedIds!;
220
- for (const segment of loaderSegments) {
221
- yield segment;
222
- }
223
- } else {
224
- state.matchedIds = state.cachedMatchedIds!;
225
- }
226
- } else {
227
- if (resolveLoadersOnlyWithRevalidation) {
228
- const loaderResult = await ctx.Store.run(() =>
229
- resolveLoadersOnlyWithRevalidation(
230
- entries,
231
- ctx.handlerContext,
232
- ctx.clientSegmentSet,
233
- ctx.prevParams,
234
- ctx.request,
235
- ctx.prevUrl,
236
- ctx.url,
237
- ctx.routeKey,
238
- ctx.actionContext,
239
- // Loaders are never cached in the segment cache, so segment staleness
240
- // must not propagate; browser-sent staleness (ctx.stale) still must.
241
- ctx.stale || undefined,
242
- ),
243
- );
244
- state.matchedIds = [
245
- ...state.cachedMatchedIds!,
246
- ...loaderResult.matchedIds,
247
- ];
248
- for (const segment of loaderResult.segments) {
249
- yield segment;
250
- }
251
- } else {
252
- state.matchedIds = state.cachedMatchedIds!;
149
+ type StoredSegmentSource =
150
+ | {
151
+ kind: "segment-cache";
152
+ segments: ResolvedSegment[];
153
+ shouldRevalidate: boolean;
154
+ scope: CacheScope;
155
+ restoreHandles?: (sentIds: ReadonlySet<string>) => Promise<void>;
253
156
  }
254
- }
157
+ | { kind: "prerender-build" | "prerender-overlay"; entry: PrerenderEntry };
255
158
 
256
- if (ms) {
257
- const loaderEnd = performance.now();
258
- ms.metrics.push({
259
- label: "pipeline:loader-resolve",
260
- duration: loaderEnd - loaderStart,
261
- startTime: loaderStart - ms.requestStart,
262
- depth: 1,
263
- });
264
- ms.metrics.push({
265
- label: "pipeline:cache-hit",
266
- duration: loaderEnd - pipelineStart,
267
- startTime: pipelineStart - ms.requestStart,
268
- });
269
- }
270
- }
271
-
272
- async function* yieldFromStore<TEnv>(
159
+ /**
160
+ * Decode a prerender entry's segments and replay its handle data into the
161
+ * request's HandleStore, as the cache scope's lookup does for a record; on a
162
+ * partial request, only the buckets of the segments it `sends`.
163
+ */
164
+ async function decodeStoredEntry<TEnv>(
273
165
  entry: PrerenderEntry,
274
166
  ctx: MatchContext<TEnv>,
275
- state: MatchPipelineState,
276
- pipelineStart: number,
277
167
  reqCtx: RequestContext<TEnv> | undefined,
278
- resolveLoadersOnly: RouterContext<TEnv>["resolveLoadersOnly"],
279
- resolveLoadersOnlyWithRevalidation: RouterContext<TEnv>["resolveLoadersOnlyWithRevalidation"],
280
- /**
281
- * The entry may be newer than the client's copy: an on-demand overlay entry
282
- * that a refresh (possibly inside this very action) replaced. On an action
283
- * the route's own segments are then re-sent, as a live action re-render
284
- * re-sends them (revalidation.ts "action:route-segment"); the immutable
285
- * bundled manifest keeps the client's copy.
286
- */
287
- replaceable?: boolean,
288
- ): AsyncGenerator<ResolvedSegment> {
289
- if (
290
- !_deserializeSegments ||
291
- !_fragmentSegments ||
292
- !_restoreHandles ||
293
- !_decodeHandles ||
294
- !_restoreRecordHandles ||
295
- !_hashParams
296
- ) {
297
- throw new Error("yieldFromStore called before ensurePrerenderDeps");
298
- }
299
-
168
+ sends: (segment: ResolvedSegment) => boolean | Promise<boolean>,
169
+ ): Promise<ResolvedSegment[]> {
300
170
  // Shell-HIT tail (issue #700): a Prerender+ppr route's tail serves from THIS
301
171
  // store (the prerender lookup runs before the cache scope), so the fragment
302
172
  // splice must apply here too — otherwise producer B entries re-serialize the
303
173
  // whole tree per request while producer A entries do not.
304
174
  const segments = reqCtx?._shellFragmentPayload
305
- ? await _fragmentSegments(entry.segments)
306
- : await _deserializeSegments(entry.segments);
175
+ ? await codec!.fragmentSegments(entry.segments)
176
+ : await codec!.deserializeSegments(entry.segments);
307
177
 
308
- // Replay handle data (same as runtime cache hit path). entry.handles is a
309
- // Flight-encoded string ("" when none) — decode before restore so
310
- // Promise/ReactNode handle values are revived, not the corrupted JSON form.
178
+ // entry.handles is a Flight-encoded string ("" when none) — decode before
179
+ // restore so Promise/ReactNode handle values are revived, not the corrupted
180
+ // JSON form.
311
181
  const handleStore = reqCtx?._handleStore;
312
182
  if (handleStore && entry.handles) {
313
- const handlesRecord = await _decodeHandles(entry.handles);
183
+ const handlesRecord = await handleSnapshot!.decodeHandles(entry.handles);
314
184
  if (handlesRecord) {
315
- _restoreHandles(handlesRecord, handleStore);
185
+ // On a partial request, a bucket is replayed only when a segment with
186
+ // its id is sent: not one a kept segment holds (the client keeps its
187
+ // values, as on a live walk), nor one of an intercept the variant does
188
+ // not serve (selectInterceptVariant).
189
+ if (!ctx.isFullMatch) {
190
+ const sent = new Set<string>();
191
+ for (const segment of segments) {
192
+ let send = sends(segment);
193
+ if (typeof send !== "boolean") send = await send;
194
+ if (send) sent.add(segment.id);
195
+ }
196
+ for (const id of Object.keys(handlesRecord)) {
197
+ if (!sent.has(id)) delete handlesRecord[id];
198
+ }
199
+ }
200
+ handleSnapshot!.restoreHandles(handlesRecord, handleStore);
316
201
  }
317
202
  }
318
203
 
319
204
  // Before the render barrier freezes the document snapshot.
320
205
  const shellHandles = reqCtx?._shellImplicitCache?.prerenderHandles;
321
206
  if (handleStore && shellHandles) {
322
- await _restoreRecordHandles!(
207
+ await handleSnapshot!.restoreRecordHandles(
323
208
  handleStore,
324
209
  shellHandles,
325
210
  loaderPins(ctx.entries, reqCtx),
326
211
  );
327
212
  }
328
213
 
329
- state.cacheHit = true;
330
- state.cacheSource = "prerender";
331
- state.cachedSegments = segments;
332
- state.cachedMatchedIds = segments.map((s) => s.id);
333
-
334
- // Set streaming flag (once) and resolve render barrier.
335
214
  // Post-match serve-source truth for the PPR replay reporter. This overwrites
336
215
  // `intercept` because the prerender store is the response source.
216
+ if (reqCtx) reqCtx._pprReplayPostMatchReason = "prerender-store";
217
+ return segments;
218
+ }
219
+
220
+ /**
221
+ * Whether a cache() record's segment is sent: one the client lacks, an
222
+ * intercept's, or one its revalidate rules re-render. Without rules, the
223
+ * route's own segments are re-rendered when the search or the route params
224
+ * changed. A segment with rules answers with their pending decision, which
225
+ * serveStoredSegments awaits and observes.
226
+ */
227
+ function cachedSegmentSends<TEnv>(
228
+ ctx: MatchContext<TEnv>,
229
+ cacheStale: boolean,
230
+ reqCtx: RequestContext<TEnv> | undefined,
231
+ router: RouterContext<TEnv>,
232
+ ): (segment: ResolvedSegment) => boolean | Promise<boolean> {
233
+ const entryRevalidateMap =
234
+ !ctx.isFullMatch &&
235
+ ctx.clientSegmentSet.size > 0 &&
236
+ router.buildEntryRevalidateMap
237
+ ? router.buildEntryRevalidateMap(ctx.entries)
238
+ : undefined;
239
+ const searchChanged =
240
+ ctx.prevUrl.search !== ctx.url.search || reqCtx?._visitorMoved === true;
241
+ const routeParamsChanged = !paramsEqual(ctx.matched.params, ctx.prevParams);
242
+ const trace = (
243
+ segment: ResolvedSegment,
244
+ shouldRevalidate: boolean,
245
+ reason: string,
246
+ ): boolean => {
247
+ if (isTraceActive()) {
248
+ pushRevalidationTraceEntry({
249
+ segmentId: segment.id,
250
+ segmentType: segment.type,
251
+ belongsToRoute: segment.belongsToRoute ?? false,
252
+ source: "cache-hit",
253
+ defaultShouldRevalidate: shouldRevalidate,
254
+ finalShouldRevalidate: shouldRevalidate,
255
+ reason,
256
+ });
257
+ }
258
+ return shouldRevalidate;
259
+ };
260
+
261
+ return (segment) => {
262
+ if (!ctx.clientSegmentSet.has(segment.id)) {
263
+ return trace(segment, true, "new-segment");
264
+ }
265
+ // A fill keeps a held segment outright (fillSends), an intercept's
266
+ // included.
267
+ if (
268
+ fillSends(ctx.handlerContext, segment.id, ctx.clientSegmentSet) === false
269
+ ) {
270
+ return false;
271
+ }
272
+ if (isInterceptSegment(segment)) return true;
273
+
274
+ const entryInfo = entryRevalidateMap?.get(segment.id);
275
+ if (!entryInfo || entryInfo.revalidate.length === 0) {
276
+ const shouldDefaultRevalidate =
277
+ (searchChanged || routeParamsChanged) &&
278
+ (segment.type === "route" ||
279
+ (segment.belongsToRoute === true &&
280
+ (segment.type === "layout" || segment.type === "parallel")));
281
+ return trace(
282
+ segment,
283
+ shouldDefaultRevalidate,
284
+ !shouldDefaultRevalidate
285
+ ? "cached-no-rules"
286
+ : routeParamsChanged
287
+ ? "cached-params-changed"
288
+ : "cached-search-changed",
289
+ );
290
+ }
291
+
292
+ return router.evaluateRevalidation({
293
+ segment,
294
+ prevParams: ctx.prevParams,
295
+ getPrevSegment: null,
296
+ request: ctx.request,
297
+ prevUrl: ctx.prevUrl,
298
+ nextUrl: ctx.url,
299
+ revalidations: entryInfo.revalidate.map((fn, i) => ({
300
+ name: `revalidate${i}`,
301
+ fn,
302
+ })),
303
+ routeKey: ctx.routeKey,
304
+ context: ctx.handlerContext,
305
+ actionContext: ctx.actionContext,
306
+ cacheStale,
307
+ traceSource: "cache-hit",
308
+ });
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Whether a prerender entry's segment is sent. On a partial navigation the
314
+ * client keeps the segments it holds. The route's own segments (an
315
+ * intercept's included) are sent again when the params changed (e.g., a
316
+ * different guide slug). An overlay entry may be newer than the client's
317
+ * copy: on an action or a stale request they are re-sent too, as a live
318
+ * action re-render re-sends them (revalidation.ts "action:route-segment");
319
+ * the immutable bundled manifest keeps the client's copy. A held segment
320
+ * above the route stays held, as a live walk keeps it ("nav:non-route-skip"),
321
+ * unless its revalidate() rules ask for it when the params changed.
322
+ *
323
+ * Decided once per decoded segment, by object: an intercept variant can hold
324
+ * a layout's parallel and the intercept's slot under one id. decodeStoredEntry
325
+ * asks first, to replay the handle values of the sent segments only.
326
+ */
327
+ function storedEntrySends<TEnv>(
328
+ ctx: MatchContext<TEnv>,
329
+ replaceable: boolean,
330
+ router: RouterContext<TEnv>,
331
+ ): (segment: ResolvedSegment) => boolean | Promise<boolean> {
332
+ if (ctx.isFullMatch) return () => true;
333
+ const paramsChanged = !paramsEqual(ctx.matched.params, ctx.prevParams);
334
+ const resendRouteSegments = replaceable && (ctx.isAction || ctx.stale);
335
+ const entryRevalidateMap =
336
+ paramsChanged && router.buildEntryRevalidateMap
337
+ ? router.buildEntryRevalidateMap(ctx.entries)
338
+ : undefined;
339
+ const decide = (segment: ResolvedSegment): boolean | Promise<boolean> => {
340
+ if (!ctx.clientSegmentSet.has(segment.id)) return true;
341
+ // A fill keeps it (fillSends).
342
+ const fill = fillSends(
343
+ ctx.handlerContext,
344
+ segment.id,
345
+ ctx.clientSegmentSet,
346
+ );
347
+ if (fill !== undefined) return fill;
348
+ if (segment.type === "route" || segment.belongsToRoute === true) {
349
+ return paramsChanged || resendRouteSegments;
350
+ }
351
+ const revalidate = entryRevalidateMap?.get(segment.id)?.revalidate;
352
+ if (!revalidate?.length) return false;
353
+ return router.evaluateRevalidation({
354
+ segment,
355
+ prevParams: ctx.prevParams,
356
+ getPrevSegment: null,
357
+ request: ctx.request,
358
+ prevUrl: ctx.prevUrl,
359
+ nextUrl: ctx.url,
360
+ revalidations: revalidate.map((fn, i) => ({
361
+ name: `revalidate${i}`,
362
+ fn,
363
+ })),
364
+ routeKey: ctx.routeKey,
365
+ context: ctx.handlerContext,
366
+ actionContext: ctx.actionContext,
367
+ stale: ctx.stale,
368
+ traceSource: "cache-hit",
369
+ });
370
+ };
371
+ const decided = new WeakMap<ResolvedSegment, boolean | Promise<boolean>>();
372
+ return (segment) => {
373
+ let send = decided.get(segment);
374
+ if (send === undefined) decided.set(segment, (send = decide(segment)));
375
+ return send;
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Serve a stored match from the first source that answers (the order is in
381
+ * the module header), or pass `upstream` through when none does. Every
382
+ * StoredSegmentSource serves here: the stored segments, minus the ones the
383
+ * client keeps, then the loaders resolved fresh (loaders are never cached or
384
+ * pre-rendered). By source: a prerender entry is decoded here, a cache()
385
+ * record also resolves the entries above its boundary live and sets
386
+ * state.shouldRevalidate, and each decides which held segments are sent
387
+ * again.
388
+ *
389
+ * The reads and the serve share this generator: a HIT is the request hot
390
+ * path, and serving from a second generator behind `yield*` adds a microtask
391
+ * turn and two promises for each segment it yields, and once more at its end.
392
+ */
393
+ async function* serveStoredSegments<TEnv>(
394
+ ctx: MatchContext<TEnv>,
395
+ state: MatchPipelineState,
396
+ upstream: AsyncGenerator<ResolvedSegment>,
397
+ ): AsyncGenerator<ResolvedSegment> {
398
+ const pipelineStart = performance.now();
399
+
400
+ // Eagerly capture the HandleStore before any async operations.
401
+ // In workerd/Cloudflare, dynamic imports and fetch() inside the pipeline
402
+ // can disrupt AsyncLocalStorage, causing getRequestContext() to return
403
+ // undefined afterward. Capturing the reference early ensures handle replay
404
+ // and handler handle-push work regardless of ALS state.
405
+ const reqCtx = _getRequestContext<TEnv>();
406
+ // Only the match can determine interception; the source header proves
407
+ // nothing in either direction. Clear a stale reason on normal matches.
337
408
  if (reqCtx) {
338
- reqCtx._pprReplayPostMatchReason = "prerender-store";
339
- if (reqCtx._treeHasStreaming === undefined) {
340
- reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
409
+ reqCtx._pprReplayPostMatchReason = ctx.isIntercept
410
+ ? "intercept"
411
+ : undefined;
412
+ }
413
+ // ALS-backed: captured before the first await, like reqCtx.
414
+ const router = getRouterContext<TEnv>();
415
+ let stored: StoredSegmentSource | null = null;
416
+
417
+ // An on-demand route may have no build-baked entry yet still needs a
418
+ // writable-overlay lookup, so od joins pr in the gate. The retained producer
419
+ // is NEVER run by this pipeline: a miss falls through like pr + miss.
420
+ const isPassthroughPrerenderRoute = ctx.entries.some(
421
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
422
+ );
423
+ const overlayEligible =
424
+ ctx.matched.od === true && !requestHeaders(ctx.request).get("X-RSC-HMR");
425
+ // Actions normally re-render fresh and skip the prerender store. But a pure
426
+ // Prerender route's handler is evicted at build, so there is no fresh
427
+ // handler to run on an action re-render -- without the fallback the
428
+ // re-render falls through to the evicted handler and throws "No prerender
429
+ // data found". Serve the prerendered entry instead (the action ran already;
430
+ // its result is applied client-side via useActionState). A plain on-demand
431
+ // route is the same case, served from the overlay first (an overlay entry
432
+ // is newer than the build one, and may be the only one). Passthrough
433
+ // routes keep a liveHandler, so a live render (rendersLive) re-renders
434
+ // them fresh. A thrown action's boundary pass walks live too
435
+ // (ActionContext.boundary).
436
+ if (
437
+ (prerenderStoreShortCircuits(ctx.matched.pr, ctx.request) ||
438
+ overlayEligible) &&
439
+ (!rendersLive(ctx) || !isPassthroughPrerenderRoute) &&
440
+ !ctx.actionContext?.boundary
441
+ ) {
442
+ await ensurePrerenderDeps();
443
+ stored = await readPrerenderSource(
444
+ ctx,
445
+ reqCtx,
446
+ isPassthroughPrerenderRoute,
447
+ // Per-request writable prerender overlay (durable), resolved by the handler.
448
+ reqCtx?._prerender,
449
+ );
450
+ }
451
+
452
+ if (
453
+ !stored &&
454
+ !rendersLive(ctx) &&
455
+ !ctx.matched.pr &&
456
+ globalThis.__PRERENDER_DEV_URL &&
457
+ ctx.entries.some(
458
+ (e) =>
459
+ (e.type === "layout" || e.type === "route" || e.type === "parallel") &&
460
+ e.isStaticPrerender,
461
+ )
462
+ ) {
463
+ await ensurePrerenderDeps();
464
+ stored = await readPrerenderSource(
465
+ ctx,
466
+ reqCtx,
467
+ isPassthroughPrerenderRoute,
468
+ );
469
+ }
470
+
471
+ stored ??= await readSegmentCacheSource(ctx, reqCtx);
472
+ if (!stored) {
473
+ yield* upstream;
474
+ const ms = ctx.metricsStore;
475
+ if (ms) {
476
+ ms.metrics.push({
477
+ label: "pipeline:cache-miss",
478
+ duration: performance.now() - pipelineStart,
479
+ startTime: pipelineStart - ms.requestStart,
480
+ });
341
481
  }
342
- reqCtx._resolveRenderBarrier(segments);
482
+ return;
483
+ }
484
+ const source: StoredSegmentSource = stored;
485
+
486
+ const fromCache = source.kind === "segment-cache";
487
+ const storedSends = fromCache
488
+ ? undefined
489
+ : storedEntrySends(ctx, source.kind === "prerender-overlay", router);
490
+ const segments = fromCache
491
+ ? source.segments
492
+ : await decodeStoredEntry(source.entry, ctx, reqCtx, storedSends!);
493
+
494
+ // Entries above the cache() boundary are not in the record: resolve them
495
+ // fresh, exactly as an uncached render of this request would.
496
+ const scope = fromCache ? source.scope : undefined;
497
+ const boundary = scope?.boundary;
498
+ const boundaryIndex =
499
+ boundary === undefined
500
+ ? 0
501
+ : Math.max(
502
+ 0,
503
+ ctx.entries.findIndex((e) => e.shortCode === boundary),
504
+ );
505
+ let liveSegments: ResolvedSegment[] = [];
506
+ let liveMatchedIds: string[] = [];
507
+ if (scope && boundaryIndex > 0) {
508
+ const liveEntries = ctx.entries.slice(0, boundaryIndex);
509
+ const live: { segments: ResolvedSegment[]; matchedIds: string[] } =
510
+ await ctx.Store.run(async () => {
511
+ // The full chain keeps a bare cache() marker, which also sits in
512
+ // its layout's orphan list, out of the live pass: the record
513
+ // supplies it and its subtree (issue #918).
514
+ const result = await router.resolveAllSegments(
515
+ ctx.isFullMatch
516
+ ? freshPassInput(
517
+ ctx.handlerContext,
518
+ ctx.routeKey,
519
+ ctx.matched.params,
520
+ )
521
+ : resolutionPassInput(ctx),
522
+ liveEntries,
523
+ {
524
+ interceptResult: ctx.interceptResult,
525
+ localRouteName: ctx.localRouteName,
526
+ pathname: ctx.pathname,
527
+ chain: ctx.entries,
528
+ },
529
+ );
530
+ return ctx.isFullMatch
531
+ ? {
532
+ segments: result.segments,
533
+ matchedIds: result.segments.map((s) => s.id),
534
+ }
535
+ : result;
536
+ });
537
+ // The record supplies every covered segment.
538
+ liveSegments = live.segments.filter(
539
+ (s) => !scope.covers(s.id, s.namespace),
540
+ );
541
+ liveMatchedIds = live.matchedIds.filter((id) => !scope.covers(id));
343
542
  }
344
543
 
345
- // For partial navigation, nullify components the client already has
346
- // so parent layouts stay live (client keeps its existing versions).
347
- // When params changed (e.g., different guide slug), the segments have
348
- // different content, so we must NOT nullify.
349
- const paramsChanged =
350
- !ctx.isFullMatch && !paramsEqual(ctx.matched.params, ctx.prevParams);
351
- const resendRouteSegments = replaceable === true && ctx.isAction;
544
+ state.cacheHit = true;
545
+ state.cacheSource = fromCache ? "runtime" : "prerender";
546
+ if (fromCache) state.shouldRevalidate = source.shouldRevalidate;
547
+ state.cachedSegments = segments;
548
+ const cachedMatchedIds = [...liveMatchedIds, ...segments.map((s) => s.id)];
549
+ state.cachedMatchedIds = cachedMatchedIds;
550
+
551
+ const sends = fromCache
552
+ ? cachedSegmentSends(ctx, source.shouldRevalidate, reqCtx, router)
553
+ : storedSends!;
554
+ const restoreHandles = fromCache ? source.restoreHandles : undefined;
555
+ const sentIds = restoreHandles && new Set<string>();
556
+
557
+ yield* liveSegments;
558
+
352
559
  for (const segment of segments) {
353
560
  if (interceptFillSkips(ctx, segment)) continue;
354
- if (!ctx.isFullMatch && ctx.clientSegmentSet.has(segment.id)) {
355
- // A fill keeps it (fillSends); otherwise it is sent again when the
356
- // params changed or an action re-sends the route's own segments.
357
- const sends =
358
- fillSends(ctx.handlerContext, segment.id, ctx.clientSegmentSet) ??
359
- (paramsChanged ||
360
- (resendRouteSegments &&
361
- (segment.type === "route" || segment.belongsToRoute === true)));
362
- if (!sends) keepClientSegment(segment);
561
+ let send = sends(segment);
562
+ if (typeof send !== "boolean") {
563
+ // A cache() record segment's revalidate() rules decided. Awaited here,
564
+ // not chained in cachedSegmentSends: a .then() costs a turn and a
565
+ // promise per segment.
566
+ send = await send;
567
+ observeEvent({
568
+ type: "revalidation.decision",
569
+ timestamp: performance.now(),
570
+ segmentId: segment.id,
571
+ pathname: ctx.pathname,
572
+ routeKey: ctx.routeKey,
573
+ shouldRevalidate: send,
574
+ });
363
575
  }
576
+ if (!send) keepClientSegment(segment);
577
+ else sentIds?.add(segment.id);
364
578
  yield segment;
365
579
  }
366
580
 
367
- // Resolve loaders fresh (loaders are never pre-rendered/cached).
368
- yield* resolveFreshLoadersAndYield(
369
- ctx,
370
- ctx.entries,
371
- state,
372
- pipelineStart,
373
- ctx.metricsStore,
374
- resolveLoadersOnly,
375
- resolveLoadersOnlyWithRevalidation,
376
- );
581
+ // A partial cache() HIT replays handler values for the segments it sends
582
+ // only, decided above after the live entries: a kept segment keeps the
583
+ // client's, as on a live walk (handle-snapshot.ts restoreHandles;
584
+ // decodeStoredEntry for a bake).
585
+ if (sentIds) await restoreHandles!(sentIds);
586
+
587
+ // The stored handler values stand where the MISS's handlers pushed them:
588
+ // behind what a segment's loaders push before the walk reaches its handler
589
+ // (HandleStore.holdRecordedValues). A fill holds nothing: on a fill MISS the
590
+ // browser's merge appends what the fill runs to the held handler values.
591
+ if (!isFillRequest(ctx.handlerContext)) {
592
+ reqCtx?._handleStore.holdRecordedValues(segments.map((s) => s.id));
593
+ }
594
+
595
+ if (reqCtx) {
596
+ if (reqCtx._treeHasStreaming === undefined) {
597
+ reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
598
+ }
599
+ reqCtx._resolveRenderBarrier(
600
+ liveSegments.length > 0 ? [...liveSegments, ...segments] : segments,
601
+ );
602
+ }
603
+
604
+ // Resolve loaders fresh. The live pass already resolved the loaders of the
605
+ // entries above the boundary.
606
+ const loaderStart = performance.now();
607
+ const loaderEntries = ctx.entries.slice(boundaryIndex);
608
+ const { resolveLoadersOnly } = router;
609
+ if (resolveLoadersOnly) {
610
+ const loaderResult = await ctx.Store.run(() =>
611
+ resolveLoadersOnly(
612
+ ctx.isFullMatch
613
+ ? freshPassInput(ctx.handlerContext, ctx.routeKey, ctx.matched.params)
614
+ : {
615
+ ...resolutionPassInput(ctx),
616
+ // Loaders are never cached in the segment cache, so segment
617
+ // staleness must not propagate; browser-sent staleness
618
+ // (ctx.stale) still must.
619
+ stale: ctx.stale || undefined,
620
+ },
621
+ loaderEntries,
622
+ { interceptResult: ctx.interceptResult },
623
+ ),
624
+ );
625
+ state.matchedIds = ctx.isFullMatch
626
+ ? cachedMatchedIds
627
+ : [...cachedMatchedIds, ...loaderResult.matchedIds];
628
+ for (const segment of loaderResult.segments) {
629
+ yield segment;
630
+ }
631
+ } else {
632
+ state.matchedIds = cachedMatchedIds;
633
+ }
634
+
635
+ const ms = ctx.metricsStore;
636
+ if (ms) {
637
+ const loaderEnd = performance.now();
638
+ ms.metrics.push({
639
+ label: "pipeline:loader-resolve",
640
+ duration: loaderEnd - loaderStart,
641
+ startTime: loaderStart - ms.requestStart,
642
+ depth: 1,
643
+ });
644
+ ms.metrics.push({
645
+ label: "pipeline:cache-hit",
646
+ duration: loaderEnd - pipelineStart,
647
+ startTime: pipelineStart - ms.requestStart,
648
+ });
649
+ }
377
650
  }
378
651
 
379
652
  /**
@@ -384,7 +657,7 @@ async function* yieldFromStore<TEnv>(
384
657
  * misses the store and renders live, and replay — including its heal
385
658
  * capture — must stay available for it (withCacheStore records the doc
386
659
  * record on that path; state.cacheSource is not "prerender"). The store
387
- * memoizes per routeKey/paramHash, so this probe and tryPrerenderLookup's
660
+ * memoizes per routeKey/paramHash, so this probe and readPrerenderSource's
388
661
  * subsequent get() share one underlying load.
389
662
  */
390
663
  export async function prerenderEntryExists(
@@ -480,23 +753,17 @@ export function scheduleOverlayRevalidation<TEnv>(
480
753
  }
481
754
 
482
755
  /**
483
- * Look up a prerendered (build-time cached) entry for the current route and, on
484
- * a hit, yield its segments. Returns true when an entry was served (the caller
485
- * should stop the pipeline) and false on a miss. Intercept navigations consult
486
- * only the intercept-specific entry (`paramHash + "/i"`); a miss there falls
487
- * through to the normal pipeline so intercept-resolution can run. Callers must
488
- * guard on `prerenderStoreInstance` after `ensurePrerenderDeps()`.
756
+ * The prerendered (build-time cached) entry for the current route, or null on
757
+ * a miss. Intercept navigations consult only the intercept-specific entry
758
+ * (`paramHash + "/i"`); a miss there falls through to the normal pipeline so
759
+ * intercept-resolution can run. Callers run `ensurePrerenderDeps()` first.
489
760
  */
490
- async function* tryPrerenderLookup<TEnv>(
761
+ async function readPrerenderSource<TEnv>(
491
762
  ctx: MatchContext<TEnv>,
492
- state: MatchPipelineState,
493
- pipelineStart: number,
494
763
  reqCtx: RequestContext<TEnv> | undefined,
495
- resolveLoadersOnly: RouterContext<TEnv>["resolveLoadersOnly"],
496
- resolveLoadersOnlyWithRevalidation: RouterContext<TEnv>["resolveLoadersOnlyWithRevalidation"],
497
764
  isPassthroughPrerenderRoute: boolean,
498
765
  overlay?: ResolvedPrerender,
499
- ): AsyncGenerator<ResolvedSegment, boolean> {
766
+ ): Promise<StoredSegmentSource | null> {
500
767
  // 1. Writable durable overlay (per-request, env-scoped), read before the
501
768
  // bundled manifest: it is always newer. Only on-demand routes are read
502
769
  // (router.prerender() refuses non-od routes, so a pr-only route always
@@ -545,19 +812,9 @@ async function* tryPrerenderLookup<TEnv>(
545
812
  (
546
813
  ctx.handlerContext as InternalHandlerContext<any, TEnv>
547
814
  )._prerenderRemoved = true;
548
- return false;
815
+ return null;
549
816
  }
550
- yield* yieldFromStore(
551
- stored.entry,
552
- ctx,
553
- state,
554
- pipelineStart,
555
- reqCtx,
556
- resolveLoadersOnly,
557
- resolveLoadersOnlyWithRevalidation,
558
- true,
559
- );
560
- return true;
817
+ return { kind: "prerender-overlay", entry: stored.entry };
561
818
  }
562
819
  }
563
820
 
@@ -572,21 +829,143 @@ async function* tryPrerenderLookup<TEnv>(
572
829
  isPassthroughRoute: isPassthroughPrerenderRoute,
573
830
  },
574
831
  );
575
- if (entry) {
576
- yield* yieldFromStore(
577
- entry,
578
- ctx,
579
- state,
580
- pipelineStart,
581
- reqCtx,
582
- resolveLoadersOnly,
583
- resolveLoadersOnlyWithRevalidation,
832
+ // The variant holds every intercept the build found; serve the one the
833
+ // match selected, or render live.
834
+ const selected = ctx.isIntercept ? ctx.interceptResult : null;
835
+ const served =
836
+ entry && selected
837
+ ? storeModule!.selectInterceptVariant(
838
+ entry,
839
+ interceptSlotId(selected.entry, selected.intercept),
840
+ selected.intercept.routeName,
841
+ )
842
+ : entry;
843
+ if (served) return { kind: "prerender-build", entry: served };
844
+ }
845
+
846
+ return null;
847
+ }
848
+
849
+ /**
850
+ * The cache() record that answers this request, or null when it renders: no
851
+ * enabled scope, a live render, a request that reads no record, or a lookup
852
+ * that did not hit.
853
+ */
854
+ async function readSegmentCacheSource<TEnv>(
855
+ ctx: MatchContext<TEnv>,
856
+ reqCtx: RequestContext<TEnv> | undefined,
857
+ ): Promise<StoredSegmentSource | null> {
858
+ // A document HIT tail replays the handler layer through the implicit doc
859
+ // scope and never runs a handler: without that scope (a context that lost
860
+ // the marker before the scope resolved), fail like a lookup miss instead
861
+ // of falling through to the handlers below.
862
+ const tailMarker = reqCtx?._shellImplicitCache;
863
+ if (tailMarker?.docTail && !ctx.cacheScope?.isShellImplicitDocScope) {
864
+ throw new ShellRecordUnavailableError(tailMarker.fixedDocKey);
865
+ }
866
+
867
+ // A prefetch that defers a layout above the boundary (prefetch: false)
868
+ // emits nothing the record holds: reading it would replay the handle
869
+ // pushes of segments the response does not carry. withCacheStore skips
870
+ // the write for the same request.
871
+ if (
872
+ rendersLive(ctx) ||
873
+ !ctx.cacheScope?.enabled ||
874
+ !recordUse(ctx.handlerContext).read
875
+ ) {
876
+ return null;
877
+ }
878
+
879
+ // Only a ppr route's records hold loader pushes, and only its shell pins
880
+ // loaders (CachedEntryData.handleOwners, the loader seed): any other
881
+ // route's record restores as a plain replay.
882
+ const leaf = ctx.entries[ctx.entries.length - 1];
883
+ const ownedPushes =
884
+ leaf !== undefined && isPprEntry(leaf)
885
+ ? () => loaderPins(ctx.entries, reqCtx)
886
+ : undefined;
887
+ // A partial request restores the handle values once it decided which
888
+ // segments it sends (serveStoredSegments).
889
+ const deferHandles = !ctx.isFullMatch;
890
+ const explicitLookup = await ctx.cacheScope.lookupRouteDetailed(
891
+ ctx.pathname,
892
+ ctx.matched.params,
893
+ interceptRecordKey(ctx.interceptResult),
894
+ ownedPushes,
895
+ deferHandles,
896
+ );
897
+ let cacheResult =
898
+ explicitLookup.status === "hit" ? explicitLookup.result : null;
899
+ // The scope whose record answered decides which entries it covers.
900
+ let hitScope: CacheScope = ctx.cacheScope;
901
+
902
+ // PPR navigation replay composed with a route-derived cache() scope. The
903
+ // explicit tier stays authoritative: its hit serves under its own
904
+ // key/ttl/swr semantics and reports `explicit-cache-hit` — never a false
905
+ // replay HIT. ONLY a true `miss` lets the seeded doc record supply the
906
+ // match (the marker's onHit observer then reports the true HIT). The
907
+ // other outcomes render fresh: `bypass` (cache(false), a false
908
+ // condition() — absolute opt-outs even when the pre-read gate saw a
909
+ // different condition() result — or no store) and `error` (a throwing
910
+ // key()/keyGenerator/store.get keeps lookupRoute's render-uncached
911
+ // contract; the canonical record must not serve across a broken key
912
+ // partition). The outcome comes from the lookup itself, not a re-run of
913
+ // the condition, so a flapping predicate cannot re-admit the fallback.
914
+ // Gated on the marker's `onExplicitHit`, set ONLY on the
915
+ // navigation-replay serve path: a CAPTURE render must never fall back
916
+ // here — its marker store reads through to the real store, and a
917
+ // doc-keyed hit would replay the previous generation's segments instead
918
+ // of re-running handlers (breaking SWR recapture freshness). Intercepts
919
+ // stay source-dependent on their normal cache path (match-api never arms
920
+ // replay for them).
921
+ const replayMarker = reqCtx?._shellImplicitCache;
922
+ if (
923
+ replayMarker?.onExplicitHit &&
924
+ !ctx.isIntercept &&
925
+ !ctx.cacheScope.isShellImplicitDocScope
926
+ ) {
927
+ if (explicitLookup.status === "hit") {
928
+ replayMarker.onExplicitHit();
929
+ } else if (explicitLookup.status === "miss" && replayMarker.store) {
930
+ // The store gate keeps report-only markers (installed on the
931
+ // no-eligible-snapshot path purely for truthful status) inert: a
932
+ // store-less marker minting a doc scope here would resolve the APP
933
+ // store and read the REAL doc: partition — a cross-partition serve.
934
+ hitScope = createShellImplicitDocScope(replayMarker);
935
+ cacheResult = await hitScope.lookupRoute(
936
+ ctx.pathname,
937
+ ctx.matched.params,
938
+ undefined,
939
+ ownedPushes,
940
+ deferHandles,
584
941
  );
585
- return true;
942
+ } else if (explicitLookup.status === "bypass") {
943
+ // condition() refused at lookup time (the gate only pre-decides the
944
+ // static cache(false) case) — report cache-disabled truthfully.
945
+ replayMarker.onExplicitBypass?.();
586
946
  }
947
+ // "error" stays unreported: the render is fresh and the seeded record
948
+ // was not consulted, which is exactly what snapshot-miss describes; the
949
+ // store already routed the failure through reportCacheError.
587
950
  }
588
951
 
589
- return false;
952
+ if (!cacheResult) {
953
+ // A document shell HIT tail replays the handler layer from the entry's
954
+ // doc record and must never run a handler behind the committed prelude:
955
+ // a record that did not hit (it failed to decode, or the entry lost it)
956
+ // ends the tail here; serveShellHit degrades the response.
957
+ if (replayMarker?.docTail) {
958
+ throw new ShellRecordUnavailableError(replayMarker.fixedDocKey);
959
+ }
960
+ return null;
961
+ }
962
+ return {
963
+ kind: "segment-cache",
964
+ segments: cacheResult.segments,
965
+ shouldRevalidate: cacheResult.shouldRevalidate,
966
+ scope: hitScope,
967
+ restoreHandles: cacheResult.restoreHandles,
968
+ };
590
969
  }
591
970
 
592
971
  /**
@@ -614,437 +993,18 @@ export type GeneratorMiddleware<T> = (
614
993
  /**
615
994
  * Creates cache lookup middleware
616
995
  *
617
- * Checks cache for segments. If cache hit:
618
- * - Applies revalidation to determine which segments need re-rendering
619
- * - Resolves loaders fresh (loaders are NOT cached by design)
620
- * - Sets state.cacheHit = true
621
- * - Sets state.shouldRevalidate if SWR needed
622
- * - Yields cached segments + fresh loader segments
996
+ * The middleware is serveStoredSegments. It serves a stored match when one
997
+ * answers, in this order: the prerender overlay and build store (Prerender
998
+ * and on-demand routes), the dev Static store, then the route's cache()
999
+ * record. It yields the stored segments and the loaders resolved fresh and
1000
+ * sets state.cacheHit (and state.shouldRevalidate for a cache() record, for
1001
+ * background SWR).
623
1002
  *
624
- * If cache miss:
625
- * - Passes through to next middleware
1003
+ * If nothing answers, passes through to the next middleware.
626
1004
  */
627
1005
  export function withCacheLookup<TEnv>(
628
1006
  ctx: MatchContext<TEnv>,
629
1007
  state: MatchPipelineState,
630
1008
  ): GeneratorMiddleware<ResolvedSegment> {
631
- return async function* (
632
- source: AsyncGenerator<ResolvedSegment>,
633
- ): AsyncGenerator<ResolvedSegment> {
634
- const pipelineStart = performance.now();
635
- const ms = ctx.metricsStore;
636
-
637
- // Eagerly capture the HandleStore before any async operations.
638
- // In workerd/Cloudflare, dynamic imports and fetch() inside the pipeline
639
- // can disrupt AsyncLocalStorage, causing getRequestContext() to return
640
- // undefined afterward. Capturing the reference early ensures handle replay
641
- // and handler handle-push work regardless of ALS state.
642
- const pipelineReqCtx = _getRequestContext<TEnv>();
643
- // Only the match can determine interception; the source header proves
644
- // nothing in either direction. Clear a stale reason on normal matches.
645
- if (pipelineReqCtx) {
646
- pipelineReqCtx._pprReplayPostMatchReason = ctx.isIntercept
647
- ? "intercept"
648
- : undefined;
649
- }
650
- // Per-request writable prerender overlay (durable), resolved by the handler.
651
- const prerenderOverlay = pipelineReqCtx?._prerender;
652
-
653
- const {
654
- evaluateRevalidation,
655
- buildEntryRevalidateMap,
656
- resolveLoadersOnlyWithRevalidation,
657
- resolveLoadersOnly,
658
- resolveAllSegments,
659
- resolveAllSegmentsWithRevalidation,
660
- } = getRouterContext<TEnv>();
661
-
662
- // An on-demand route may have no build-baked entry yet still needs a
663
- // writable-overlay lookup, so od joins pr in the gate. The retained producer
664
- // is NEVER run by this pipeline: a miss falls through like pr + miss.
665
- const isPassthroughPrerenderRoute = ctx.entries.some(
666
- (entry) => entry.type === "route" && entry.isPassthrough === true,
667
- );
668
- const overlayEligible =
669
- ctx.matched.od === true && !requestHeaders(ctx.request).get("X-RSC-HMR");
670
- if (
671
- prerenderStoreShortCircuits(ctx.matched.pr, ctx.request) ||
672
- overlayEligible
673
- ) {
674
- // Actions normally re-render fresh and skip the prerender store. But a pure
675
- // Prerender route's handler is evicted at build, so there is no fresh
676
- // handler to run on an action re-render -- without the fallback the
677
- // re-render falls through to the evicted handler and throws "No prerender
678
- // data found". Serve the prerendered entry instead (the action ran already;
679
- // its result is applied client-side via useActionState). A plain on-demand
680
- // route is the same case, served from the overlay first (an overlay entry
681
- // is newer than the build one, and may be the only one). Passthrough
682
- // routes keep a liveHandler, so they still re-render fresh on actions.
683
- if (!ctx.isAction || !isPassthroughPrerenderRoute) {
684
- await ensurePrerenderDeps();
685
- if (prerenderStoreInstance || prerenderOverlay) {
686
- const served = yield* tryPrerenderLookup(
687
- ctx,
688
- state,
689
- pipelineStart,
690
- pipelineReqCtx,
691
- resolveLoadersOnly,
692
- resolveLoadersOnlyWithRevalidation,
693
- isPassthroughPrerenderRoute,
694
- prerenderOverlay,
695
- );
696
- if (served) return;
697
- }
698
- }
699
- }
700
-
701
- if (!ctx.isAction && !ctx.matched.pr && globalThis.__PRERENDER_DEV_URL) {
702
- const hasStatic = ctx.entries.some(
703
- (e) =>
704
- (e.type === "layout" ||
705
- e.type === "route" ||
706
- e.type === "parallel") &&
707
- e.isStaticPrerender,
708
- );
709
- if (hasStatic) {
710
- await ensurePrerenderDeps();
711
- if (prerenderStoreInstance) {
712
- const served = yield* tryPrerenderLookup(
713
- ctx,
714
- state,
715
- pipelineStart,
716
- pipelineReqCtx,
717
- resolveLoadersOnly,
718
- resolveLoadersOnlyWithRevalidation,
719
- isPassthroughPrerenderRoute,
720
- );
721
- if (served) return;
722
- }
723
- }
724
- }
725
-
726
- // A document HIT tail replays the handler layer through the implicit doc
727
- // scope and never runs a handler: without that scope (a context that lost
728
- // the marker before the scope resolved), fail like a lookup miss instead
729
- // of falling through to the handlers below.
730
- const tailMarker = pipelineReqCtx?._shellImplicitCache;
731
- if (tailMarker?.docTail && !ctx.cacheScope?.isShellImplicitDocScope) {
732
- throw new ShellRecordUnavailableError(tailMarker.fixedDocKey);
733
- }
734
-
735
- // A prefetch that defers a layout above the boundary (prefetch: false)
736
- // emits nothing the record holds: reading it would replay the handle
737
- // pushes of segments the response does not carry. withCacheStore skips
738
- // the write for the same request.
739
- if (
740
- ctx.isAction ||
741
- !ctx.cacheScope?.enabled ||
742
- !recordUse(ctx.handlerContext).read
743
- ) {
744
- yield* source;
745
- if (ms) {
746
- ms.metrics.push({
747
- label: "pipeline:cache-miss",
748
- duration: performance.now() - pipelineStart,
749
- startTime: pipelineStart - ms.requestStart,
750
- });
751
- }
752
- return;
753
- }
754
-
755
- // Only a ppr route's records hold loader pushes, and only its shell pins
756
- // loaders (CachedEntryData.handleOwners, the loader seed): any other
757
- // route's record restores as a plain replay.
758
- const leaf = ctx.entries[ctx.entries.length - 1];
759
- const ownedPushes =
760
- leaf !== undefined && isPprEntry(leaf)
761
- ? () => loaderPins(ctx.entries, pipelineReqCtx)
762
- : undefined;
763
- const explicitLookup = await ctx.cacheScope.lookupRouteDetailed(
764
- ctx.pathname,
765
- ctx.matched.params,
766
- ctx.isIntercept,
767
- ownedPushes,
768
- );
769
- let cacheResult =
770
- explicitLookup.status === "hit" ? explicitLookup.result : null;
771
- // The scope whose record answered decides which entries it covers.
772
- let hitScope: CacheScope = ctx.cacheScope;
773
-
774
- // PPR navigation replay composed with a route-derived cache() scope. The
775
- // explicit tier stays authoritative: its hit serves under its own
776
- // key/ttl/swr semantics and reports `explicit-cache-hit` — never a false
777
- // replay HIT. ONLY a true `miss` lets the seeded doc record supply the
778
- // match (the marker's onHit observer then reports the true HIT). The
779
- // other outcomes render fresh: `bypass` (cache(false), a false
780
- // condition() — absolute opt-outs even when the pre-read gate saw a
781
- // different condition() result — or no store) and `error` (a throwing
782
- // key()/keyGenerator/store.get keeps lookupRoute's render-uncached
783
- // contract; the canonical record must not serve across a broken key
784
- // partition). The outcome comes from the lookup itself, not a re-run of
785
- // the condition, so a flapping predicate cannot re-admit the fallback.
786
- // Gated on the marker's `onExplicitHit`, set ONLY on the
787
- // navigation-replay serve path: a CAPTURE render must never fall back
788
- // here — its marker store reads through to the real store, and a
789
- // doc-keyed hit would replay the previous generation's segments instead
790
- // of re-running handlers (breaking SWR recapture freshness). Intercepts
791
- // stay source-dependent on their normal cache path (match-api never arms
792
- // replay for them).
793
- const replayMarker = pipelineReqCtx?._shellImplicitCache;
794
- if (
795
- replayMarker?.onExplicitHit &&
796
- !ctx.isIntercept &&
797
- !ctx.cacheScope.isShellImplicitDocScope
798
- ) {
799
- if (explicitLookup.status === "hit") {
800
- replayMarker.onExplicitHit();
801
- } else if (explicitLookup.status === "miss" && replayMarker.store) {
802
- // The store gate keeps report-only markers (installed on the
803
- // no-eligible-snapshot path purely for truthful status) inert: a
804
- // store-less marker minting a doc scope here would resolve the APP
805
- // store and read the REAL doc: partition — a cross-partition serve.
806
- hitScope = createShellImplicitDocScope(replayMarker);
807
- cacheResult = await hitScope.lookupRoute(
808
- ctx.pathname,
809
- ctx.matched.params,
810
- ctx.isIntercept,
811
- ownedPushes,
812
- );
813
- } else if (explicitLookup.status === "bypass") {
814
- // condition() refused at lookup time (the gate only pre-decides the
815
- // static cache(false) case) — report cache-disabled truthfully.
816
- replayMarker.onExplicitBypass?.();
817
- }
818
- // "error" stays unreported: the render is fresh and the seeded record
819
- // was not consulted, which is exactly what snapshot-miss describes; the
820
- // store already routed the failure through reportCacheError.
821
- }
822
-
823
- if (!cacheResult) {
824
- // A document shell HIT tail replays the handler layer from the entry's
825
- // doc record and must never run a handler behind the committed prelude:
826
- // a record that did not hit (it failed to decode, or the entry lost it)
827
- // ends the tail here; serveShellHit degrades the response.
828
- if (replayMarker?.docTail) {
829
- throw new ShellRecordUnavailableError(replayMarker.fixedDocKey);
830
- }
831
- yield* source;
832
- if (ms) {
833
- ms.metrics.push({
834
- label: "pipeline:cache-miss",
835
- duration: performance.now() - pipelineStart,
836
- startTime: pipelineStart - ms.requestStart,
837
- });
838
- }
839
- return;
840
- }
841
-
842
- // Entries above the cache() boundary are not in the record: resolve them
843
- // fresh, exactly as an uncached render of this request would.
844
- const boundaryIndex =
845
- hitScope.boundary === undefined
846
- ? 0
847
- : Math.max(
848
- 0,
849
- ctx.entries.findIndex((e) => e.shortCode === hitScope.boundary),
850
- );
851
- let liveSegments: ResolvedSegment[] = [];
852
- let liveMatchedIds: string[] = [];
853
- if (boundaryIndex > 0) {
854
- const liveEntries = ctx.entries.slice(0, boundaryIndex);
855
- const live: { segments: ResolvedSegment[]; matchedIds: string[] } =
856
- await ctx.Store.run(async () => {
857
- if (!ctx.isFullMatch) {
858
- return resolveAllSegmentsWithRevalidation(
859
- liveEntries,
860
- ctx.routeKey,
861
- ctx.matched.params,
862
- ctx.handlerContext,
863
- ctx.clientSegmentSet,
864
- ctx.prevParams,
865
- ctx.request,
866
- ctx.prevUrl,
867
- ctx.url,
868
- ctx.actionContext,
869
- ctx.interceptResult,
870
- ctx.localRouteName,
871
- ctx.pathname,
872
- ctx.stale,
873
- ctx.entries,
874
- );
875
- }
876
- // The full chain keeps a bare cache() marker, which also sits in
877
- // its layout's orphan list, out of the live pass: the record
878
- // supplies it and its subtree (issue #918).
879
- const segments = await resolveAllSegments(
880
- liveEntries,
881
- ctx.routeKey,
882
- ctx.matched.params,
883
- ctx.handlerContext,
884
- ctx.loaderPromises,
885
- { chain: ctx.entries },
886
- );
887
- return { segments, matchedIds: segments.map((s) => s.id) };
888
- });
889
- // The record supplies every covered segment.
890
- liveSegments = live.segments.filter(
891
- (s) => !hitScope.covers(s.id, s.namespace),
892
- );
893
- liveMatchedIds = live.matchedIds.filter((id) => !hitScope.covers(id));
894
- }
895
-
896
- state.cacheHit = true;
897
- state.cacheSource = "runtime";
898
- state.shouldRevalidate = cacheResult.shouldRevalidate;
899
- state.cachedSegments = cacheResult.segments;
900
- state.cachedMatchedIds = [
901
- ...liveMatchedIds,
902
- ...cacheResult.segments.map((s) => s.id),
903
- ];
904
-
905
- const canCheckSegmentRevalidation =
906
- !ctx.isFullMatch &&
907
- ctx.clientSegmentSet.size > 0 &&
908
- !!buildEntryRevalidateMap;
909
- const entryRevalidateMap = canCheckSegmentRevalidation
910
- ? buildEntryRevalidateMap(ctx.entries)
911
- : undefined;
912
- const searchChanged =
913
- ctx.prevUrl.search !== ctx.url.search ||
914
- pipelineReqCtx?._visitorMoved === true;
915
- const routeParamsChanged = !paramsEqual(ctx.matched.params, ctx.prevParams);
916
-
917
- yield* liveSegments;
918
-
919
- for (const segment of cacheResult.segments) {
920
- if (interceptFillSkips(ctx, segment)) continue;
921
- if (!ctx.clientSegmentSet.has(segment.id)) {
922
- if (isTraceActive()) {
923
- pushRevalidationTraceEntry({
924
- segmentId: segment.id,
925
- segmentType: segment.type,
926
- belongsToRoute: segment.belongsToRoute ?? false,
927
- source: "cache-hit",
928
- defaultShouldRevalidate: true,
929
- finalShouldRevalidate: true,
930
- reason: "new-segment",
931
- });
932
- }
933
- yield segment;
934
- continue;
935
- }
936
-
937
- // A fill keeps a held segment outright (fillSends), an intercept's
938
- // included.
939
- if (
940
- fillSends(ctx.handlerContext, segment.id, ctx.clientSegmentSet) ===
941
- false
942
- ) {
943
- keepClientSegment(segment);
944
- yield segment;
945
- continue;
946
- }
947
- if (isInterceptSegment(segment)) {
948
- yield segment;
949
- continue;
950
- }
951
-
952
- const entryInfo = entryRevalidateMap?.get(segment.id);
953
- const shouldDefaultRevalidate =
954
- (searchChanged || routeParamsChanged) &&
955
- (segment.type === "route" ||
956
- (segment.belongsToRoute &&
957
- (segment.type === "layout" || segment.type === "parallel")));
958
-
959
- if (!entryInfo || entryInfo.revalidate.length === 0) {
960
- if (shouldDefaultRevalidate) {
961
- if (isTraceActive()) {
962
- pushRevalidationTraceEntry({
963
- segmentId: segment.id,
964
- segmentType: segment.type,
965
- belongsToRoute: segment.belongsToRoute ?? false,
966
- source: "cache-hit",
967
- defaultShouldRevalidate: true,
968
- finalShouldRevalidate: true,
969
- reason: routeParamsChanged
970
- ? "cached-params-changed"
971
- : "cached-search-changed",
972
- });
973
- }
974
- yield segment;
975
- continue;
976
- }
977
- if (isTraceActive()) {
978
- pushRevalidationTraceEntry({
979
- segmentId: segment.id,
980
- segmentType: segment.type,
981
- belongsToRoute: segment.belongsToRoute ?? false,
982
- source: "cache-hit",
983
- defaultShouldRevalidate: false,
984
- finalShouldRevalidate: false,
985
- reason: "cached-no-rules",
986
- });
987
- }
988
- keepClientSegment(segment);
989
- yield segment;
990
- continue;
991
- }
992
-
993
- const shouldRevalidate = await evaluateRevalidation({
994
- segment,
995
- prevParams: ctx.prevParams,
996
- getPrevSegment: null,
997
- request: ctx.request,
998
- prevUrl: ctx.prevUrl,
999
- nextUrl: ctx.url,
1000
- revalidations: entryInfo.revalidate.map((fn, i) => ({
1001
- name: `revalidate${i}`,
1002
- fn,
1003
- })),
1004
- routeKey: ctx.routeKey,
1005
- context: ctx.handlerContext,
1006
- actionContext: ctx.actionContext,
1007
- stale: cacheResult.shouldRevalidate || ctx.stale || undefined,
1008
- traceSource: "cache-hit",
1009
- });
1010
-
1011
- observeEvent({
1012
- type: "revalidation.decision",
1013
- timestamp: performance.now(),
1014
- segmentId: segment.id,
1015
- pathname: ctx.pathname,
1016
- routeKey: ctx.routeKey,
1017
- shouldRevalidate,
1018
- });
1019
-
1020
- if (!shouldRevalidate) keepClientSegment(segment);
1021
-
1022
- yield segment;
1023
- }
1024
-
1025
- const barrierReqCtx = pipelineReqCtx;
1026
- if (barrierReqCtx) {
1027
- if (barrierReqCtx._treeHasStreaming === undefined) {
1028
- barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
1029
- }
1030
- barrierReqCtx._resolveRenderBarrier(
1031
- liveSegments.length > 0
1032
- ? [...liveSegments, ...cacheResult.segments]
1033
- : cacheResult.segments,
1034
- );
1035
- }
1036
-
1037
- // Resolve loaders fresh (loaders are never cached). Shared with the
1038
- // prerender-store path via resolveFreshLoadersAndYield. The live pass
1039
- // already resolved the loaders of the entries above the boundary.
1040
- yield* resolveFreshLoadersAndYield(
1041
- ctx,
1042
- ctx.entries.slice(boundaryIndex),
1043
- state,
1044
- pipelineStart,
1045
- ms,
1046
- resolveLoadersOnly,
1047
- resolveLoadersOnlyWithRevalidation,
1048
- );
1049
- };
1009
+ return (upstream) => serveStoredSegments(ctx, state, upstream);
1050
1010
  }