@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -9,18 +9,50 @@
9
9
  import {
10
10
  getRequestContext,
11
11
  setRequestContextParams,
12
+ runWithRequestContext,
12
13
  } from "../server/request-context.js";
14
+ import {
15
+ SeededShellStore,
16
+ buildShellLoaderSeed,
17
+ } from "../cache/shell-snapshot.js";
13
18
  import { appendMetric } from "../router/metrics.js";
14
19
  import { observePhase, PHASES } from "../router/instrument.js";
15
20
  import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
16
21
  import type { RscPayload } from "./types.js";
17
- import type { MatchResult } from "../types.js";
22
+ import type { SSRModule } from "./types.js";
23
+ import type { RequestContext } from "../server/request-context.js";
18
24
  import {
19
25
  createResponseWithMergedHeaders,
20
26
  createSimpleRedirectResponse,
21
27
  attachLocationStateIfPresent,
22
28
  } from "./helpers.js";
23
29
  import type { HandlerContext } from "./handler-context.js";
30
+ import { gateTransitions } from "./transition-gate.js";
31
+ import { buildFullPayload } from "./full-payload.js";
32
+ import {
33
+ scheduleShellCapture,
34
+ type ShellCaptureDescriptor,
35
+ } from "./shell-capture.js";
36
+ import {
37
+ SHELL_STATUS_HEADER,
38
+ resolvePprConfig,
39
+ buildShellKey,
40
+ isValidShellHit,
41
+ hasIntactShellPayload,
42
+ base64ToBytes,
43
+ hasShellFamily,
44
+ warnShellStoreMissingOnce,
45
+ warnPprNonceActiveOnce,
46
+ } from "./shell-serve.js";
47
+ import { contextGet } from "../context-var.js";
48
+ import {
49
+ resolveSameOriginRedirect,
50
+ safeSameOriginLanding,
51
+ } from "../redirect-origin.js";
52
+ import { nonce as nonceToken } from "./nonce.js";
53
+ import { reportCacheError } from "../cache/cache-error.js";
54
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
55
+ import type { ShellCacheEntry } from "../cache/types.js";
24
56
 
25
57
  export function handleRscRendering<TEnv>(
26
58
  ctx: HandlerContext<TEnv>,
@@ -63,37 +95,138 @@ async function handleRscRenderingInner<TEnv>(
63
95
  let payload: RscPayload;
64
96
  let hasInterceptSlots = false;
65
97
 
66
- // Shared by the partial-fallback and full-render paths. The partial-success
67
- // payload below is intentionally different (omits rootLayout/theme, adds slots).
68
- const buildFullPayload = (m: MatchResult): RscPayload => ({
69
- metadata: {
70
- pathname: url.pathname,
71
- routerId: ctx.router.id,
72
- basename: ctx.router.basename,
73
- segments: m.segments,
74
- matched: m.matched,
75
- diff: m.diff,
76
- resolvedIds: m.resolvedIds,
77
- params: m.params,
78
- isPartial: false,
79
- rootLayout: ctx.router.rootLayout,
80
- handles: handleStore.stream(),
81
- version: ctx.version,
82
- prefetchCacheTTL: ctx.router.prefetchCacheTTL,
83
- stateCookieName: ctx.router.resolvedStateCookieName,
84
- themeConfig: ctx.router.themeConfig,
85
- // Carry warmupEnabled on the initial full-render payload so the client
86
- // respects warmup:false from first load. The 404 and PE payloads already
87
- // include it; without it here warmup could never be disabled on the
88
- // normal full-load path (partial payloads omit it by design).
89
- warmupEnabled: ctx.router.warmupEnabled,
90
- // Carry strictMode on the initial full-render payload so the browser
91
- // entry knows whether to wrap hydration in React.StrictMode. Partial
92
- // (navigation) payloads omit it by design; StrictMode is decided once.
93
- strictMode: ctx.router.strictMode,
94
- initialTheme: reqCtx.theme,
95
- },
96
- });
98
+ // --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
99
+ //
100
+ // COMMIT POINT. This function is the render pass executeRender wraps, so it runs
101
+ // strictly AFTER the whole middleware chain — the global router.use() chain AND
102
+ // route DSL middleware() both wrap it. Any middleware rejection/redirect/401 has
103
+ // already returned before this line, which is what makes a shared shell safe:
104
+ // not a single shell byte can precede a guard decision, on MISS or HIT.
105
+ //
106
+ // PPR is opt-in per PAGE ROUTE via the `ppr` path option (read off the classified
107
+ // route snapshot — the same matched entry match() will resolve). No `ppr` option
108
+ // means pure axis 1: no store read, no capture, no logs, zero cost.
109
+ //
110
+ // On a valid HIT the composed response is committed HERE — the stored prelude
111
+ // bytes flush immediately while match()/segment resolution/Flight render/resume
112
+ // run behind them inside the response stream (ring-3 reads and render setup hide
113
+ // behind wire bytes). On a MISS the request continues as plain axis 1 and a
114
+ // background capture is scheduled after the response is built.
115
+ let pprMiss: {
116
+ descriptor: ShellCaptureDescriptor;
117
+ ssrModule: SSRModule;
118
+ } | null = null;
119
+ if (
120
+ !isPartial &&
121
+ request.method === "GET" &&
122
+ !url.searchParams.has("__prerender_collect") &&
123
+ !isRscRequest(request, url, false)
124
+ ) {
125
+ const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
126
+ if (pprConfig) {
127
+ // A per-request CSP nonce pins the route to axis 1: useNonce() (and any app
128
+ // code reading the nonce) renders it into every nonced script/style/meta, so
129
+ // a shell shared per host+URL would freeze one request's nonce for every
130
+ // visitor and the browser's CSP would reject the frozen nonce for all but the
131
+ // capture request. The nonce arrives two ways and BOTH must gate: the
132
+ // createRouter({ nonce }) provider (threaded here as `nonce`), and a direct
133
+ // token write in middleware (ctx.set(nonce, value)). The token is only
134
+ // visible in the post-middleware request variables — and this commit point
135
+ // runs AFTER the whole middleware chain (see the block header), so it is
136
+ // present here. Reading it closes the gap the provider-only check left open
137
+ // (issue #656). The threaded-param check stays first: the provider path is
138
+ // resolved before any variable read and short-circuits cheaply.
139
+ const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
140
+ const store = reqCtx._cacheStore;
141
+ const key = buildShellKey(url);
142
+ if (activeNonce !== undefined) {
143
+ // Declared intent that cannot be honored deserves a diagnostic (unlike an
144
+ // undeclared route, which is silent): a ppr route gated off by an active
145
+ // per-request nonce warns once per key. Axis 1 after the warning.
146
+ warnPprNonceActiveOnce(key);
147
+ } else if (!hasShellFamily(store)) {
148
+ // Declared intent that cannot be honored deserves a diagnostic (unlike an
149
+ // undeclared route, which is silent). Axis 1 after the warning.
150
+ warnShellStoreMissingOnce(key);
151
+ } else {
152
+ // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
153
+ // streaming, so bots/SEO crawlers get one complete axis-1 document.
154
+ const [ssrModule, streamMode] = await getSSRSetup(
155
+ ctx,
156
+ request,
157
+ env,
158
+ url,
159
+ reqCtx._metricsStore,
160
+ );
161
+ if (
162
+ streamMode !== "allReady" &&
163
+ ssrModule.resumeShellHTML &&
164
+ ssrModule.captureShellHTML
165
+ ) {
166
+ const descriptor: ShellCaptureDescriptor = {
167
+ key,
168
+ buildVersion: ctx.version,
169
+ ttl: pprConfig.ttl,
170
+ swr: pprConfig.swr,
171
+ tags: pprConfig.tags,
172
+ store,
173
+ debug: INTERNAL_RANGO_DEBUG,
174
+ };
175
+ let cached: Awaited<ReturnType<typeof store.getShell>> = null;
176
+ try {
177
+ cached = await store.getShell(key);
178
+ } catch (error) {
179
+ // A failing store read degrades to axis 1 (MISS), never a 500.
180
+ reportCacheError(error, "cache-read", "[ShellServe] getShell");
181
+ }
182
+ if (cached && isValidShellHit(cached.entry, ctx.version)) {
183
+ if (!hasIntactShellPayload(cached.entry)) {
184
+ // Corrupt stored payload (undecodable prelude / unparseable
185
+ // postponed): a store-layer fault worth a diagnostic, unlike the
186
+ // silent version-mismatch lifecycle misses above. Degrade to MISS
187
+ // — pprMiss below schedules the recapture that overwrites it.
188
+ reportCacheError(
189
+ new Error(
190
+ `corrupt shell entry for "${key}": prelude/postponed failed ` +
191
+ "the integrity check; serving axis 1 and recapturing",
192
+ ),
193
+ "cache-read",
194
+ "[ShellServe] getShell",
195
+ );
196
+ } else {
197
+ // Stale (SWR) hit: serve the stale shell now, recapture in the
198
+ // background (stampede-guarded + backoff inside scheduleShellCapture).
199
+ if (cached.shouldRevalidate) {
200
+ scheduleShellCapture(
201
+ ctx,
202
+ request,
203
+ env,
204
+ url,
205
+ reqCtx,
206
+ ssrModule,
207
+ descriptor,
208
+ );
209
+ }
210
+ return serveShellHit(
211
+ ctx,
212
+ request,
213
+ env,
214
+ url,
215
+ reqCtx,
216
+ handleStore,
217
+ ssrModule,
218
+ cached.entry,
219
+ descriptor,
220
+ );
221
+ }
222
+ }
223
+ // MISS (no entry, invalid reactVersion, or store read failure): axis 1
224
+ // + a background capture scheduled once the response is known servable.
225
+ pprMiss = { descriptor, ssrModule };
226
+ }
227
+ }
228
+ }
229
+ }
97
230
 
98
231
  if (isPartial) {
99
232
  // Partial render (navigation)
@@ -111,7 +244,7 @@ async function handleRscRenderingInner<TEnv>(
111
244
  return createSimpleRedirectResponse(match.redirect);
112
245
  }
113
246
 
114
- payload = buildFullPayload(match);
247
+ payload = buildFullPayload(match, ctx, url, reqCtx, handleStore);
115
248
  } else {
116
249
  setRequestContextParams(result.params, result.routeName);
117
250
 
@@ -126,7 +259,11 @@ async function handleRscRenderingInner<TEnv>(
126
259
  // intercepted server-side (X-RSC-Reload) and never delivers a
127
260
  // different-router payload to the client.
128
261
  routerId: ctx.router.id,
129
- segments: result.segments,
262
+ segments: gateTransitions(
263
+ result.segments,
264
+ reqCtx,
265
+ ctx.router.onError,
266
+ ),
130
267
  matched: result.matched,
131
268
  diff: result.diff,
132
269
  resolvedIds: result.resolvedIds,
@@ -136,6 +273,8 @@ async function handleRscRenderingInner<TEnv>(
136
273
  handles: handleStore.stream(),
137
274
  version: ctx.version,
138
275
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
276
+ prefetchCacheSize: ctx.router.prefetchCacheSize,
277
+ prefetchConcurrency: ctx.router.prefetchConcurrency,
139
278
  stateCookieName: ctx.router.resolvedStateCookieName,
140
279
  },
141
280
  };
@@ -183,7 +322,7 @@ async function handleRscRenderingInner<TEnv>(
183
322
  { headers: { "Content-Type": "application/json" } },
184
323
  );
185
324
  } else {
186
- payload = buildFullPayload(match);
325
+ payload = buildFullPayload(match, ctx, url, reqCtx, handleStore);
187
326
  }
188
327
  }
189
328
 
@@ -263,8 +402,261 @@ async function handleRscRenderingInner<TEnv>(
263
402
  streamMode,
264
403
  }),
265
404
  );
266
-
267
- return createResponseWithMergedHeaders(htmlStream, {
405
+ const response = createResponseWithMergedHeaders(htmlStream, {
268
406
  headers: { "content-type": "text/html;charset=utf-8" },
269
407
  });
408
+
409
+ // --- Axis 2: PPR shell CAPTURE on MISS (background task; see design doc) ---
410
+ // The ppr route missed its shell above. Schedule the background capture only
411
+ // when the served response is a 200 HTML document (a 404/error render is not a
412
+ // cacheable shell), and tag the response for observability either way. Capture
413
+ // does NOT flow through the HTTP pipeline: scheduleShellCapture re-derives the
414
+ // page via router.match() under a derived context (fresh handle store,
415
+ // _shellCaptureRun: true) — middleware never re-runs; it already ran for this
416
+ // request and guarding is serve-time.
417
+ if (pprMiss) {
418
+ if (
419
+ response.status === 200 &&
420
+ (response.headers.get("content-type") ?? "").includes("text/html")
421
+ ) {
422
+ scheduleShellCapture(
423
+ ctx,
424
+ request,
425
+ env,
426
+ url,
427
+ reqCtx,
428
+ pprMiss.ssrModule,
429
+ pprMiss.descriptor,
430
+ );
431
+ }
432
+ response.headers.set(SHELL_STATUS_HEADER, "MISS");
433
+ }
434
+
435
+ return response;
436
+ }
437
+
438
+ /**
439
+ * Neutralize the shell-HIT degradation redirect target.
440
+ *
441
+ * The inline `location.replace` emitted by serveShellHit when a shell HIT lands
442
+ * on a URL whose route became redirecting mid-TTL is a document-native redirect
443
+ * exit that BYPASSES the 3xx chokepoint (guardOutgoingRedirect acts only on 3xx
444
+ * + Location responses, never a committed 200 body). So it reuses the ONE
445
+ * same-origin resolver directly: a cross-origin/unparseable/unsafe target
446
+ * neutralizes to the same safe same-origin landing as redirect-guard.ts
447
+ * (basename root, or "/" when unset) rather than navigating the user off-host.
448
+ * A safe same-origin/relative target passes through as its normalized href.
449
+ */
450
+ export function resolveShellHitRedirectTarget(
451
+ rawTarget: string,
452
+ requestOrigin: string,
453
+ basename: string | undefined,
454
+ ): string {
455
+ return (
456
+ resolveSameOriginRedirect(rawTarget, requestOrigin) ??
457
+ safeSameOriginLanding(basename)
458
+ );
459
+ }
460
+
461
+ /**
462
+ * Serve a validated shell HIT: commit the composed response NOW — the stored
463
+ * prelude bytes are the first thing on the wire — and run the live tail
464
+ * (match(), fresh loaders, full Flight render for hydration, fizz resume of just
465
+ * the holes) BEHIND them inside the response stream. React relies on HTML-parser
466
+ * foster-parenting for content streamed after the prelude's closing
467
+ * `</body></html>`, so plain byte concatenation is the correct composition.
468
+ *
469
+ * Status and headers are committed at the flush: middleware already ran (their
470
+ * ctx.res headers merge in via createResponseWithMergedHeaders), and route
471
+ * middleware code after its next() can still adjust headers on the returned
472
+ * Response object. A failing hole cannot become a 500/redirect after this point —
473
+ * error UI renders inline via Suspense/error boundaries, the documented PPR
474
+ * constraint.
475
+ *
476
+ * The tail promise is kicked off SYNCHRONOUSLY so match/Flight/resume run inside
477
+ * the current ALS request-context frame (the stream may be pulled by the server
478
+ * adapter outside it).
479
+ */
480
+ function serveShellHit(
481
+ ctx: HandlerContext<any>,
482
+ request: Request,
483
+ env: any,
484
+ url: URL,
485
+ reqCtx: RequestContext<any>,
486
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
487
+ ssrModule: SSRModule,
488
+ entry: ShellCacheEntry,
489
+ descriptor: ShellCaptureDescriptor,
490
+ ): Response {
491
+ const preludeBytes = base64ToBytes(entry.prelude);
492
+
493
+ const renderTail = async (
494
+ activeCtx: RequestContext<any>,
495
+ ): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
496
+ const match = await ctx.router.match(request, { env });
497
+ if (match.redirect) return { redirect: match.redirect };
498
+ setRequestContextParams(match.params, match.routeName);
499
+ const payload = buildFullPayload(match, ctx, url, activeCtx, handleStore);
500
+ // Theme fidelity for resume: initialTheme is per-request METADATA (the
501
+ // visitor's cookie), but React resume requires the tree above the holes to
502
+ // match the frozen prelude, which was rendered with the CAPTURE's
503
+ // initialTheme. Replay the captured value into the payload (the SSR resume
504
+ // tree AND client hydration both read it) so the trees agree by
505
+ // construction. The visitor still sees THEIR theme: the FOUC script in the
506
+ // prelude applies it pre-paint from the cookie, and ThemeProvider re-syncs
507
+ // its state from the cookie post-mount.
508
+ if (payload.metadata) {
509
+ payload.metadata.initialTheme = entry.initialTheme as
510
+ | import("../theme/types.js").Theme
511
+ | undefined;
512
+ }
513
+ // Full Flight render per request: hydration needs the whole payload (there
514
+ // is no Flight-side resume — a React limitation, not ours).
515
+ let rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
516
+ onError: (error: unknown) => {
517
+ ctx.callOnError(error, "rendering", { request, url, env });
518
+ },
519
+ });
520
+ // Timing tap: when does the Flight render produce its FIRST byte? Compared
521
+ // with the eager-inject/first-tail logs this proves whether hydration-start
522
+ // latency is genuine server work (loaders) or stream plumbing holding
523
+ // ready bytes back.
524
+ if (INTERNAL_RANGO_DEBUG) {
525
+ const tapStart = performance.now();
526
+ let first = false;
527
+ rscStream = rscStream.pipeThrough(
528
+ new TransformStream({
529
+ transform(chunk, controller) {
530
+ if (!first) {
531
+ first = true;
532
+ console.log(
533
+ `[Server][ppr] flight render: first chunk +${Math.round(performance.now() - tapStart)}ms`,
534
+ );
535
+ }
536
+ controller.enqueue(chunk);
537
+ },
538
+ }),
539
+ );
540
+ }
541
+ return observePhase(PHASES.ssr, () =>
542
+ ssrModule.resumeShellHTML!(rscStream, {
543
+ postponed: entry.postponed,
544
+ nonce: undefined,
545
+ }),
546
+ );
547
+ };
548
+
549
+ const tailPromise: Promise<
550
+ ReadableStream<Uint8Array> | { redirect: string }
551
+ > = (async () => {
552
+ // Capture data snapshot seeding (docs/design/ppr-shell-resume.md): the tail
553
+ // is a FULL FRESH render whose payload must match the frozen prelude. If the
554
+ // capture recorded a snapshot, run the tail through a SeededShellStore
555
+ // overlay so every cache-store read the capture pinned returns its
556
+ // capture-time value AS FRESH — the shell region reproduces byte-identically
557
+ // even after the underlying cache entries drifted (expired/recomputed/
558
+ // tag-invalidated). Everything not pinned (the holes — masked loaders were
559
+ // never recorded) falls through to the real store and stays LIVE. The
560
+ // overlay lives on a DERIVED context (own _cacheStore), so the shared reqCtx
561
+ // is untouched; an entry without a snapshot keeps the pre-snapshot behavior.
562
+ if (entry.snapshot && entry.snapshot.length > 0) {
563
+ const seededCtx: RequestContext<any> = Object.create(reqCtx);
564
+ if (reqCtx._cacheStore) {
565
+ seededCtx._cacheStore = new SeededShellStore(
566
+ reqCtx._cacheStore,
567
+ entry.snapshot,
568
+ );
569
+ }
570
+ // Loader-family records (bake-lane containers, loader-container-bake):
571
+ // decode into a seed Map for the resolveLoaderData overlay, so the
572
+ // payload's baked container bytes match the frozen prelude while the
573
+ // hole-marker paths keep the fresh run's live nested promises.
574
+ const loaderSeed = await buildShellLoaderSeed(entry.snapshot);
575
+ if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
576
+ return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
577
+ }
578
+ return renderTail(reqCtx);
579
+ })();
580
+ // The stream below is the only consumer; pre-attach a no-op catch so a tail
581
+ // failure before the stream is pulled never surfaces as an unhandled rejection.
582
+ tailPromise.catch(() => {});
583
+
584
+ const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
585
+ const body = new ReadableStream<Uint8Array>({
586
+ async start(controller) {
587
+ controller.enqueue(preludeBytes);
588
+ if (INTERNAL_RANGO_DEBUG) {
589
+ console.log(
590
+ `[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
591
+ );
592
+ }
593
+ try {
594
+ const tail = await tailPromise;
595
+ if (tail instanceof ReadableStream) {
596
+ const reader = tail.getReader();
597
+ let firstTailChunk = true;
598
+ try {
599
+ for (;;) {
600
+ const { done, value } = await reader.read();
601
+ if (done) break;
602
+ if (INTERNAL_RANGO_DEBUG && firstTailChunk) {
603
+ firstTailChunk = false;
604
+ console.log(
605
+ `[Server][ppr] shell HIT: first tail chunk on the wire +${Math.round(performance.now() - serveStart)}ms`,
606
+ );
607
+ }
608
+ controller.enqueue(value);
609
+ }
610
+ } finally {
611
+ reader.releaseLock();
612
+ }
613
+ } else {
614
+ // Defensive, near-unreachable: a redirecting match cannot have captured
615
+ // a shell (capture bails on redirects), so a HIT on a redirecting URL
616
+ // requires the route to have BECOME redirecting within the shell TTL.
617
+ // The 200 + prelude are already committed; degrade to a client-side
618
+ // replace so the user still lands on the target. The target is
619
+ // neutralized first (see resolveShellHitRedirectTarget).
620
+ const safeTarget = resolveShellHitRedirectTarget(
621
+ tail.redirect,
622
+ url.origin,
623
+ ctx.router.basename,
624
+ );
625
+ controller.enqueue(
626
+ new TextEncoder().encode(
627
+ `<script>location.replace(${JSON.stringify(safeTarget)})</script>`,
628
+ ),
629
+ );
630
+ }
631
+ controller.close();
632
+ } catch (error) {
633
+ // Self-heal on a failed tail: the pre-commit gates (isValidShellHit +
634
+ // hasIntactShellPayload) cannot catch a parseable-but-mismatched
635
+ // postponed blob or a hard render error above the holes — those throw
636
+ // here, AFTER the 200 + prelude flushed, and would otherwise re-fail on
637
+ // every request until the entry ages out (nothing else evicts it).
638
+ // Recapturing overwrites the entry with one the current server
639
+ // produced. A client disconnect mid-stream also lands here and
640
+ // schedules a spurious-but-idempotent recapture — bounded by the
641
+ // stampede guard + backoff inside scheduleShellCapture.
642
+ scheduleShellCapture(
643
+ ctx,
644
+ request,
645
+ env,
646
+ url,
647
+ reqCtx,
648
+ ssrModule,
649
+ descriptor,
650
+ );
651
+ controller.error(error);
652
+ }
653
+ },
654
+ });
655
+
656
+ return createResponseWithMergedHeaders(body, {
657
+ headers: {
658
+ "content-type": "text/html;charset=utf-8",
659
+ [SHELL_STATUS_HEADER]: "HIT",
660
+ },
661
+ });
270
662
  }
@@ -21,6 +21,7 @@ import {
21
21
  } from "../server/request-context.js";
22
22
  import { appendMetric } from "../router/metrics.js";
23
23
  import { observePhase, PHASES } from "../router/instrument.js";
24
+ import { gateTransitions } from "./transition-gate.js";
24
25
  import type { RscPayload } from "./types.js";
25
26
  import {
26
27
  hasBodyContent,
@@ -363,6 +364,20 @@ async function revalidateAfterActionInner<TEnv>(
363
364
  const reqCtx = getRequestContext();
364
365
  const metricsStore = reqCtx._metricsStore;
365
366
 
367
+ // Expose the action that triggered this revalidation to the transition({ when })
368
+ // gate (covers both the error-boundary and success gate calls below). Mirrors
369
+ // the action fields a revalidate() predicate sees.
370
+ reqCtx._gateActionId = actionContext?.actionId;
371
+ reqCtx._gateActionUrl = actionContext?.actionUrl;
372
+ reqCtx._gateActionResult = actionContext?.actionResult;
373
+ reqCtx._gateFormData = actionContext?.formData;
374
+
375
+ // Mark the rest of this request as an action revalidation render. The "use
376
+ // cache" runtime reads this to re-execute a stale entry in the foreground
377
+ // (fresh data in the action response) rather than serving stale + revalidating
378
+ // in the background. See registerCachedFunction in cache/cache-runtime.ts.
379
+ reqCtx._inActionRevalidation = true;
380
+
366
381
  // Action threw and a boundary matched: render the (already-matched) error
367
382
  // boundary here so it runs inside the route-middleware wrapper, exactly like
368
383
  // the success branch below. setRequestContextParams + the payload mirror the
@@ -376,7 +391,11 @@ async function revalidateAfterActionInner<TEnv>(
376
391
  // routerId exposed for the frontend (current app identity); see
377
392
  // rsc-rendering.ts partial branch.
378
393
  routerId: ctx.router.id,
379
- segments: errorBoundary.segments,
394
+ segments: gateTransitions(
395
+ errorBoundary.segments,
396
+ reqCtx,
397
+ ctx.router.onError,
398
+ ),
380
399
  isPartial: true,
381
400
  matched: errorBoundary.matched,
382
401
  diff: errorBoundary.diff,
@@ -460,7 +479,11 @@ async function revalidateAfterActionInner<TEnv>(
460
479
  // routerId exposed for the frontend (current app identity); see
461
480
  // rsc-rendering.ts partial branch.
462
481
  routerId: ctx.router.id,
463
- segments: matchResult.segments,
482
+ segments: gateTransitions(
483
+ matchResult.segments,
484
+ reqCtx,
485
+ ctx.router.onError,
486
+ ),
464
487
  isPartial: true,
465
488
  matched: matchResult.matched,
466
489
  diff: matchResult.diff,