@rangojs/router 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -18,20 +18,32 @@
18
18
 
19
19
  import React from "react";
20
20
  import { isPprEntry, type EntryData } from "../server/context.js";
21
- import { sortedSearchString } from "../cache/cache-key-utils.js";
22
- import type { SearchParamsFilter } from "../cache/search-params-filter.js";
21
+ import { base64ToBytes } from "../cache/cf/cf-base64.js";
23
22
  import type {
24
23
  DocumentShellCacheEntry,
25
24
  ShellCacheEntry,
25
+ ShellDocumentRead,
26
+ ShellEntryHead,
27
+ ShellReadStats,
28
+ ShellSnapshotFailure,
29
+ ShellSnapshotRecord,
26
30
  SegmentCacheStore,
27
31
  } from "../cache/types.js";
28
- import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
32
+ import {
33
+ SHELL_CAPTURE_MAX_WAIT_MS,
34
+ hasWarnedOnce,
35
+ resetShellWarningsForTests,
36
+ warnOnce,
37
+ } from "./shell-capture-constants.js";
29
38
 
30
- /** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
31
- export const SHELL_STATUS_HEADER = "x-rango-shell";
32
-
33
- /** Partial-navigation status header set only after captured PPR segments are consumed. */
34
- export const PPR_REPLAY_STATUS_HEADER = "x-rango-ppr-replay";
39
+ export {
40
+ PPR_REPLAY_STATUS_HEADER,
41
+ SHELL_STATUS_HEADER,
42
+ buildShellKey,
43
+ navigationShellKey,
44
+ partitionShellKey,
45
+ shellSearchSeed,
46
+ } from "./shell-capture-constants.js";
35
47
 
36
48
  /**
37
49
  * Default shell ttl (seconds) for `ppr: true` and for a PartialPrerenderProps
@@ -115,36 +127,59 @@ export function resolvePprConfig(
115
127
  }
116
128
 
117
129
  /**
118
- * Shell cache key: host + pathname + sorted search + a `:shell` namespace suffix
119
- * (so it can never collide with a document-cache key; the store further isolates
120
- * the shell family internally).
121
- *
122
- * The key includes the request HOST: in a multi-tenant host-router deployment
123
- * (one worker, one shared KV/runtime-cache store) a host-less key would serve
124
- * tenant A's captured shell to tenant B's users.
125
- *
126
- * `filter` is the request's compiled `cache.searchParams` config
127
- * (ctx._searchParamsFilter): excluded params collapse onto one shell slot.
128
- * Callers on the serve/capture path MUST pass it -- key drift between capture
129
- * and lookup makes every shell request a permanent miss.
130
+ * The forced-MISS query marker. A document HIT that cannot finish its tail
131
+ * reloads the page with it (shellReloadScript); the serve gate renders a
132
+ * request carrying it on axis 1, with no shell read and no capture, so the
133
+ * reload can never be a HIT that degrades again. The handler drops it from
134
+ * the request on entry (withoutShellMissMarker) and keeps only the flag
135
+ * (RequestContext._shellForcedMiss), so nothing else sees it.
136
+ */
137
+ export const SHELL_MISS_PARAM: string = "_rsc_shell";
138
+
139
+ /**
140
+ * The request without the forced-MISS marker, or undefined when it carries
141
+ * none. The handler calls it before anything reads the request: left in, the
142
+ * marker reached `ctx.request.url`, `originalUrl`, middleware, and the SSR
143
+ * search seed (so `useSearchParams` rendered it), and a `"use cache"` key
144
+ * built from the request URL was never read again. GET and HEAD only: the
145
+ * reload is a navigation, and a request with a body cannot be re-created
146
+ * without re-streaming it. The substring test keeps the URL parse off every
147
+ * other request.
148
+ */
149
+ export function withoutShellMissMarker(request: Request): Request | undefined {
150
+ if (!request.url.includes(SHELL_MISS_PARAM)) return undefined;
151
+ if (request.method !== "GET" && request.method !== "HEAD") return undefined;
152
+ const url = new URL(request.url);
153
+ if (!url.searchParams.has(SHELL_MISS_PARAM)) return undefined;
154
+ url.searchParams.delete(SHELL_MISS_PARAM);
155
+ return new Request(url, request);
156
+ }
157
+
158
+ /**
159
+ * The inline script a degraded document HIT ends with: reload once into a
160
+ * forced MISS. The marker in the URL is the loop bound — a request carrying
161
+ * it is never a HIT, so this script never runs on the reload, and if it ever
162
+ * did (a server that ignores the marker) it would return without touching
163
+ * the page. window.stop() runs only when it reloads: it keeps the half-sent
164
+ * Flight stream from being closed by DOMContentLoaded, which would throw
165
+ * React's "Connection closed" (#412) before the reload lands.
130
166
  */
131
- export function buildShellKey(url: URL, filter?: SearchParamsFilter): string {
132
- return `${url.host}${url.pathname}${shellSearchSeed(url, filter)}:shell`;
167
+ export function shellReloadScript(): string {
168
+ return inlineShellScript(
169
+ "(function(){var u=new URL(location.href);" +
170
+ `if(u.searchParams.has(${JSON.stringify(SHELL_MISS_PARAM)}))return;` +
171
+ `u.searchParams.set(${JSON.stringify(SHELL_MISS_PARAM)},"miss");` +
172
+ "try{window.stop()}catch(e){}location.replace(u.href)})()",
173
+ );
133
174
  }
134
175
 
135
176
  /**
136
- * The shell key's search portion (`?`-prefixed sorted search with the
137
- * cache.searchParams filter applied, or "") — ALSO the string the capture and
138
- * resume SSR renders seed their store location with (SSRRenderOptions.search
139
- * / ShellCaptureOptions.search / ShellResumeOptions.search). Search is part
140
- * of shell identity, so static-part `useSearchParams` reads render exactly
141
- * what the key names; deriving seed and key from this one helper is what
142
- * keeps capture, resume, and lookup byte-agreed (drift = replay mismatch or
143
- * permanent MISS).
177
+ * An inline script a shell HIT's tail appends. It carries no CSP nonce: a
178
+ * request with an active per-request nonce never reaches a HIT
179
+ * (rsc-rendering.ts shellServePlan serves it on axis 1).
144
180
  */
145
- export function shellSearchSeed(url: URL, filter?: SearchParamsFilter): string {
146
- const sorted = sortedSearchString(url.searchParams, filter);
147
- return sorted ? `?${sorted}` : "";
181
+ export function inlineShellScript(body: string): string {
182
+ return `<script>${body}</script>`;
148
183
  }
149
184
 
150
185
  /**
@@ -153,12 +188,10 @@ export function shellSearchSeed(url: URL, filter?: SearchParamsFilter): string {
153
188
  * one exact tree, so resuming it under a different React OR a different app
154
189
  * build tree-mismatches inside resume() — after the 200 + prelude committed,
155
190
  * with no recovery. Either mismatch is a miss: the recapture overwrites the
156
- * same key (self-healing) and the entry otherwise ages out via TTL. An entry
157
- * with no buildVersion (stored before the field existed) is a miss for the
158
- * same reason — its build is unknown, so it cannot be proven resumable.
191
+ * same key (self-healing) and the entry otherwise ages out via TTL.
159
192
  */
160
193
  export function isValidShellHit(
161
- entry: ShellCacheEntry,
194
+ entry: Pick<ShellCacheEntry, "reactVersion" | "buildVersion">,
162
195
  buildVersion: string,
163
196
  ): boolean {
164
197
  return (
@@ -166,17 +199,25 @@ export function isValidShellHit(
166
199
  );
167
200
  }
168
201
 
202
+ /** The postponed blob is stored and parses (null is the DATA variant). */
203
+ function hasParseablePostponed<T extends ShellEntryHead>(
204
+ entry: T,
205
+ ): entry is T & { postponed: string | null } {
206
+ if (entry.postponed === undefined) return false;
207
+ try {
208
+ if (entry.postponed !== null) JSON.parse(entry.postponed);
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
169
215
  /**
170
- * DOCUMENT-half integrity gate and type narrowing, run BEFORE the HIT response
171
- * commits: a stored entry whose prelude is not decodable base64 or whose
172
- * postponed blob is not parseable JSON would otherwise throw AFTER the 200 +
173
- * full static prelude flushed (`serveShellHit` decodes at stream construction,
174
- * `resumeShellHTML` parses in the tail) — the client gets a visually complete
175
- * page that never hydrates, re-served on every request until the entry ages
176
- * out (no eviction path exists; failure schedules no recapture by itself).
177
- * Checking here turns a corrupt entry (store-layer fault) into a plain MISS
178
- * the recapture overwrites. Cost: one duplicate decode/parse per HIT, sub-ms
179
- * against a prelude flush that dominates the path.
216
+ * DOCUMENT-half structural gate and type narrowing, without decoding the
217
+ * prelude: the prelude is a string and the postponed blob parses. Partial
218
+ * replay (which never serves the prelude) and the build-manifest read-through
219
+ * use it as-is; the document HIT path runs it inside
220
+ * {@link openShellDocument}, whose single decode is the prelude's check.
180
221
  *
181
222
  * navigationOnly entries store no document half (prelude/postponed absent) and
182
223
  * therefore never pass. The partial-replay path skips this gate for them
@@ -187,25 +228,92 @@ export function isValidShellHit(
187
228
  export function hasIntactShellPayload(
188
229
  entry: ShellCacheEntry,
189
230
  ): entry is DocumentShellCacheEntry {
190
- if (typeof entry.prelude !== "string" || entry.postponed === undefined) {
191
- return false;
231
+ return typeof entry.prelude === "string" && hasParseablePostponed(entry);
232
+ }
233
+
234
+ /** A document shell ready to serve: its prelude decoded exactly once. */
235
+ export interface ShellDocument {
236
+ entry: ShellEntryHead;
237
+ prelude: Uint8Array;
238
+ /** React's postponed state, parse-checked (null = DATA variant). */
239
+ postponed: string | null;
240
+ /**
241
+ * The capture snapshot: on the entry, or still arriving on its own promise
242
+ * after a prelude-first read (SegmentCacheStore.readShellDocument), which
243
+ * only the tail awaits.
244
+ */
245
+ snapshot: ShellSnapshotRecord[] | Promise<ShellSnapshotRecord[] | undefined>;
246
+ /** Why a prelude-first read's snapshot is missing (ShellDocumentRead). */
247
+ snapshotFailure?: Promise<ShellSnapshotFailure | undefined>;
248
+ /** The prelude-first read's stats, when perf metrics are on. */
249
+ stats?: ShellReadStats;
250
+ }
251
+
252
+ /**
253
+ * Pre-commit integrity gate for a document HIT, and its only prelude decode.
254
+ * A stored entry whose prelude is not decodable base64 or whose postponed blob
255
+ * is not parseable JSON would otherwise throw AFTER the 200 + full static
256
+ * prelude flushed (`resumeShellHTML` parses in the tail) — the client gets a
257
+ * visually complete page that never hydrates, re-served on every request
258
+ * until the entry ages out. Returning null here turns a corrupt entry
259
+ * (store-layer fault) into a plain MISS the recapture overwrites. The decoded
260
+ * bytes are what serveShellHit enqueues, so the check costs no second decode.
261
+ * A prelude-first `read` (SegmentCacheStore.readShellDocument) already carries
262
+ * the raw bytes and a pending snapshot, so only its postponed blob is checked.
263
+ */
264
+ export function openShellDocument(entry: ShellCacheEntry): ShellDocument | null;
265
+ export function openShellDocument(
266
+ entry: ShellEntryHead,
267
+ read: Pick<
268
+ ShellDocumentRead,
269
+ "prelude" | "snapshot" | "snapshotFailure" | "stats"
270
+ >,
271
+ ): ShellDocument | null;
272
+ export function openShellDocument(
273
+ entry: ShellEntryHead | ShellCacheEntry,
274
+ read?: Pick<
275
+ ShellDocumentRead,
276
+ "prelude" | "snapshot" | "snapshotFailure" | "stats"
277
+ >,
278
+ ): ShellDocument | null {
279
+ if (read) {
280
+ if (!hasParseablePostponed(entry)) return null;
281
+ return {
282
+ entry,
283
+ postponed: entry.postponed,
284
+ prelude: read.prelude,
285
+ snapshot: read.snapshot,
286
+ ...(read.snapshotFailure && { snapshotFailure: read.snapshotFailure }),
287
+ ...(read.stats && { stats: read.stats }),
288
+ };
192
289
  }
290
+ // The read-less overload takes a whole entry (getShell, a build shell).
291
+ const whole = entry as ShellCacheEntry;
292
+ if (!hasIntactShellPayload(whole)) return null;
293
+ let prelude: Uint8Array;
193
294
  try {
194
- base64ToBytes(entry.prelude);
195
- if (entry.postponed !== null) JSON.parse(entry.postponed);
196
- return true;
295
+ prelude = base64ToBytes(whole.prelude);
197
296
  } catch {
198
- return false;
297
+ return null;
199
298
  }
299
+ return {
300
+ entry: whole,
301
+ postponed: whole.postponed,
302
+ prelude,
303
+ snapshot: whole.snapshot,
304
+ };
200
305
  }
201
306
 
202
- /** Decode a base64 prelude back into bytes for stream composition. */
203
- export function base64ToBytes(b64: string): Uint8Array {
204
- const binary = atob(b64);
205
- const bytes = new Uint8Array(binary.length);
206
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
207
- return bytes;
208
- }
307
+ /**
308
+ * Prelude enqueue granularity. A streaming compressor in front of the worker
309
+ * emits its first byte only after compressing the whole write it was handed:
310
+ * one 629 KB enqueue delayed the first compressed byte by the full prelude's
311
+ * compression time (brotli-6 5.1 ms, gzip-6 1.1 ms in Node zlib with a flush
312
+ * per write; workerd's CompressionStream emits nothing until the whole write is
313
+ * compressed), while 32 KB chunks emit within 0.1 ms and cost slightly more
314
+ * in total compression (0.55-0.70 ms) than one write (issue #941).
315
+ */
316
+ export const SHELL_PRELUDE_CHUNK_BYTES: number = 32 * 1024;
209
317
 
210
318
  /** True when the store implements the shell entry family. */
211
319
  export function hasShellFamily(
@@ -227,8 +335,22 @@ export function hasShellFamily(
227
335
  export interface ShellTailTiming {
228
336
  key: string;
229
337
  outcome: "complete" | "redirect" | "error";
338
+ /** The capture snapshot available to the tail (read and parsed). */
339
+ snapshotMs?: number;
340
+ /** Snapshot bytes read after the commit (prelude-first stores). */
341
+ snapshotBytes?: number;
342
+ /** Snapshot bytes read after the commit, excluding the parse. */
343
+ snapshotReadMs?: number;
344
+ /** Snapshot JSON.parse CPU (reads 0 on a deployed worker: see bytes). */
345
+ snapshotParseMs?: number;
346
+ /** Snapshot records by family, e.g. `segment:1/loader:2` (no commas: it rides a Server-Timing desc). */
347
+ snapshotRecords?: string;
348
+ /** Records the capture pruned from the snapshot, same format (ShellCacheEntry.prunedRecords). */
349
+ snapshotPruned?: string;
230
350
  /** Loader-family seed decode (only when the entry carried a snapshot). */
231
351
  seedMs?: number;
352
+ /** The seed decode's own duration: Flight deserialization, CPU only. */
353
+ seedCpuMs?: number;
232
354
  /** Tail router.match() settled. */
233
355
  matchMs?: number;
234
356
  /** Tail stream (resume output) handed to the response stream. */
@@ -244,13 +366,35 @@ export interface ShellTailTiming {
244
366
  }
245
367
 
246
368
  /**
247
- * Compact single-line form for the console log and the dev Server-Timing
248
- * mirror's `desc`. Plain alphanumerics/`=`/`-` only — no quoted-string
249
- * escaping needed.
369
+ * Compact single-line form for the console log and the Server-Timing mirror's
370
+ * `desc`: alphanumerics and `=`, `-`, `:`, `/` only (records=segment:1/loader:2 pruned=loader:1),
371
+ * so no quoted-string escaping is needed. Offsets are from the commit; the
372
+ * `-cpu` fields are CPU-only durations.
250
373
  */
251
374
  export function describeShellTailTiming(timing: ShellTailTiming): string {
252
375
  const parts: string[] = [timing.outcome];
376
+ if (timing.snapshotMs !== undefined) {
377
+ parts.push(`snapshot=${timing.snapshotMs}ms`);
378
+ }
379
+ if (timing.snapshotReadMs !== undefined) {
380
+ parts.push(`snapshot-read=${timing.snapshotReadMs}ms`);
381
+ }
382
+ if (timing.snapshotBytes !== undefined) {
383
+ parts.push(`snapshot-bytes=${timing.snapshotBytes}b`);
384
+ }
385
+ if (timing.snapshotParseMs !== undefined) {
386
+ parts.push(`snapshot-parse-cpu=${timing.snapshotParseMs}ms`);
387
+ }
388
+ if (timing.snapshotRecords !== undefined) {
389
+ parts.push(`records=${timing.snapshotRecords}`);
390
+ }
391
+ if (timing.snapshotPruned !== undefined) {
392
+ parts.push(`pruned=${timing.snapshotPruned}`);
393
+ }
253
394
  if (timing.seedMs !== undefined) parts.push(`seed=${timing.seedMs}ms`);
395
+ if (timing.seedCpuMs !== undefined) {
396
+ parts.push(`seed-cpu=${timing.seedCpuMs}ms`);
397
+ }
254
398
  if (timing.matchMs !== undefined) parts.push(`match=${timing.matchMs}ms`);
255
399
  if (timing.handoverMs !== undefined) {
256
400
  parts.push(`handover=${timing.handoverMs}ms`);
@@ -269,19 +413,19 @@ export function describeShellTailTiming(timing: ShellTailTiming): string {
269
413
  }
270
414
 
271
415
  /**
272
- * Dev-only last-tail-per-key buffer backing the `ppr:tail` Server-Timing
273
- * mirror: a HIT's tail finishes after its own headers are long gone, so its
274
- * per-stage numbers ride the NEXT ppr GET for the key when the metrics
275
- * surface is active (debugPerformance). Same shape and FIFO cap as the
276
- * capture mirror (shell-capture.ts lastCaptureEventsForTiming); dev-only so
277
- * production isolates never grow the map.
416
+ * Last-tail-per-key buffer backing the `ppr:tail` Server-Timing mirror: a
417
+ * HIT's tail finishes after its own headers are long gone, so its per-stage
418
+ * numbers ride the NEXT ppr GET for the key when the metrics surface is
419
+ * active (debugPerformance). Same shape and FIFO cap as the capture mirror
420
+ * (shell-capture.ts lastCaptureEventsForTiming). serveShellHit collects and
421
+ * publishes a timing only in dev or when the HIT itself collected metrics, so
422
+ * a production isolate without debugPerformance never grows the map.
278
423
  */
279
424
  const lastTailTimingsForServerTiming = new Map<string, ShellTailTiming>();
280
425
  const MAX_TAIL_TIMING_KEYS = 100;
281
426
 
282
- /** Buffer one terminal tail timing for the dev Server-Timing mirror. */
427
+ /** Buffer one terminal tail timing for the Server-Timing mirror. */
283
428
  export function publishShellTailTiming(timing: ShellTailTiming): void {
284
- if (process.env.NODE_ENV === "production") return;
285
429
  lastTailTimingsForServerTiming.delete(timing.key);
286
430
  if (lastTailTimingsForServerTiming.size >= MAX_TAIL_TIMING_KEYS) {
287
431
  const oldest = lastTailTimingsForServerTiming.keys().next().value;
@@ -302,9 +446,6 @@ export function takeShellTailTimingForServerTiming(
302
446
  return timing;
303
447
  }
304
448
 
305
- /** Keys already warned about a missing shell store family (once per key). */
306
- const warnedMissingStore = new Set<string>();
307
-
308
449
  /**
309
450
  * Warn once per key that a route declared `ppr` but the app-level cache store
310
451
  * does not implement the shell family (getShell/putShell), so the route stays on
@@ -312,10 +453,11 @@ const warnedMissingStore = new Set<string>();
312
453
  * honored deserves a diagnostic.
313
454
  */
314
455
  export function warnShellStoreMissingOnce(key: string): void {
315
- if (warnedMissingStore.has(key)) return;
316
- warnedMissingStore.add(key);
317
- console.warn(
318
- `[rango] Route for "${key}" declares the ppr path option, but the app-level ` +
456
+ warnOnce(
457
+ "store-missing",
458
+ key,
459
+ () =>
460
+ `[rango] Route for "${key}" declares the ppr path option, but the app-level ` +
319
461
  "cache store does not implement the shell family (getShell/putShell), so " +
320
462
  "the route is served on axis 1 without a shell. Use MemorySegmentCacheStore, " +
321
463
  "CFCacheStore, or VercelCacheStore (or add the family to your custom store) " +
@@ -323,9 +465,6 @@ export function warnShellStoreMissingOnce(key: string): void {
323
465
  );
324
466
  }
325
467
 
326
- /** Keys already warned about an active per-request nonce (once per key). */
327
- const warnedNonceActive = new Set<string>();
328
-
329
468
  /**
330
469
  * Warn once per key that a route declared `ppr` but a per-request CSP nonce is
331
470
  * active for the request, so the route stays on axis 1 (a shared shell would
@@ -337,10 +476,11 @@ const warnedNonceActive = new Set<string>();
337
476
  * the missing-store warning above (an undeclared route stays silent).
338
477
  */
339
478
  export function warnPprNonceActiveOnce(key: string): void {
340
- if (warnedNonceActive.has(key)) return;
341
- warnedNonceActive.add(key);
342
- console.warn(
343
- `[rango] Route for "${key}" declares the ppr path option, but a per-request ` +
479
+ warnOnce(
480
+ "nonce-active",
481
+ key,
482
+ () =>
483
+ `[rango] Route for "${key}" declares the ppr path option, but a per-request ` +
344
484
  "CSP nonce is active for this request (from createRouter({ nonce }) or a " +
345
485
  "ctx.set(nonce, …) token write in middleware), so the route is served on " +
346
486
  "axis 1 without a shell. A shell is shared per host+URL; baking one " +
@@ -348,3 +488,109 @@ export function warnPprNonceActiveOnce(key: string): void {
348
488
  "ppr option on this route, or stop setting a per-request nonce for it.",
349
489
  );
350
490
  }
491
+
492
+ /**
493
+ * Dev only (the caller gates on NODE_ENV): warn once per route that the
494
+ * route's cache() entry reduces an explicit `ppr.ttl` or `ppr.swr`. `shell`
495
+ * is what the capture stores for a record written just now
496
+ * (shell-capture.ts capShellWindow), so the message states the values the
497
+ * shell actually gets. A ppr value the cap leaves whole (or raises: the
498
+ * cap can move a record's stale time into the shell's swr) is silent, and
499
+ * so is a ppr config that sets no ttl/swr.
500
+ */
501
+ export function warnPprWindowCappedOnce(
502
+ routeName: string,
503
+ ppr: { ttl?: number; swr?: number },
504
+ shell: { ttl: number; swr: number },
505
+ cache: { ttl: number; swr: number },
506
+ ): void {
507
+ if (hasWarnedOnce("ppr-window-capped", routeName)) return;
508
+ const reduced: string[] = [];
509
+ for (const field of ["ttl", "swr"] as const) {
510
+ const own = ppr[field];
511
+ if (own !== undefined && shell[field] < own) {
512
+ reduced.push(`ppr.${field} ${own}`);
513
+ }
514
+ }
515
+ if (reduced.length === 0) return;
516
+ warnOnce(
517
+ "ppr-window-capped",
518
+ routeName,
519
+ () =>
520
+ `[rango] Route "${routeName}": its shell is stored with ttl ${shell.ttl} ` +
521
+ `and swr ${shell.swr}, below its ${reduced.join(" and ")}. A shell ` +
522
+ `never outlives the route cache() entry (ttl ${cache.ttl}, swr ` +
523
+ `${cache.swr}) it was captured from, so a document request and a ` +
524
+ "client navigation show the same handler output. Keep ppr.ttl within " +
525
+ "the cache() ttl, and ppr.ttl + ppr.swr within the cache() ttl + swr, " +
526
+ "to silence this.",
527
+ );
528
+ }
529
+
530
+ /**
531
+ * Paths a partitioned request found without a build shell. Build shells are
532
+ * per path (a param route prerenders some of its paths), so a negative is
533
+ * kept per path, not per route; cleared when full, so it stays bounded.
534
+ */
535
+ const pathsWithoutBuildShell = new Set<string>();
536
+ const PATHS_WITHOUT_BUILD_SHELL_MAX = 1_000;
537
+
538
+ /**
539
+ * Whether a partitioned request needs no build-shell probe: its route was
540
+ * already warned, or its path was already found without one.
541
+ */
542
+ export function partitionBuildShellCheckDone(
543
+ pathname: string,
544
+ routeName: string | undefined,
545
+ ): boolean {
546
+ return (
547
+ hasWarnedOnce("partition-build-shell", routeName ?? pathname) ||
548
+ pathsWithoutBuildShell.has(pathname)
549
+ );
550
+ }
551
+
552
+ /**
553
+ * Record a partitioned request's build-shell probe, and warn once per route
554
+ * when it found one: the route has a build-time shell its requests cannot
555
+ * read. The route's request partition (a `cache({ key })` enclosing it, at
556
+ * any depth (#970), or the store's keyGenerator) keys the shell per
557
+ * partition, and the build captured only
558
+ * the default one, so each partition captures at runtime. Same
559
+ * declared-intent-cannot-be-honored doctrine as the warnings above; a path
560
+ * with no build shell stays silent.
561
+ */
562
+ export function notePartitionBuildShellCheck(
563
+ pathname: string,
564
+ routeName: string | undefined,
565
+ found: boolean,
566
+ ): void {
567
+ if (!found) {
568
+ if (pathsWithoutBuildShell.size >= PATHS_WITHOUT_BUILD_SHELL_MAX) {
569
+ pathsWithoutBuildShell.clear();
570
+ }
571
+ pathsWithoutBuildShell.add(pathname);
572
+ return;
573
+ }
574
+ // Concurrent first requests can both probe; one of them warns.
575
+ warnOnce(
576
+ "partition-build-shell",
577
+ routeName ?? pathname,
578
+ () =>
579
+ `[rango] Route ${routeName ? `"${routeName}" ` : ""}("${pathname}") has a ` +
580
+ "build-time shell, but its request partition (a cache({ key }) enclosing the route, " +
581
+ "or the store's keyGenerator) keys its shell per partition, so the build " +
582
+ "shell is not served and each partition captures its own at runtime. A " +
583
+ "keyGenerator that returns the default key unchanged keeps the build shell.",
584
+ );
585
+ }
586
+
587
+ /**
588
+ * @internal Reset the serve path's once-per-key warnings (the shell path's
589
+ * one registry), its build-shell probe memo and buffered tail timings. Tests
590
+ * only (testing/serve-shell-request.ts resetShellTestState).
591
+ */
592
+ export function resetShellServeStateForTests(): void {
593
+ resetShellWarningsForTests();
594
+ pathsWithoutBuildShell.clear();
595
+ lastTailTimingsForServerTiming.clear();
596
+ }
@@ -6,6 +6,7 @@
6
6
  * matching, so they can run in parallel with segment resolution.
7
7
  */
8
8
 
9
+ import { requestHeaders } from "../server/request-headers.js";
9
10
  import type { HandlerContext } from "./handler-context.js";
10
11
  import type { SSRModule, SSRRenderOptions } from "./types.js";
11
12
  import type { SSRStreamMode } from "../router/router-options.js";
@@ -183,7 +184,7 @@ export function getSSRSetup<TEnv>(
183
184
  */
184
185
  function acceptsFlightExplicitly(request: Request, url: URL): boolean {
185
186
  if (url.searchParams.has("__html")) return false;
186
- const accept = request.headers.get("accept");
187
+ const accept = requestHeaders(request).get("accept");
187
188
  if (accept === null || !accept.includes(RSC_WIRE_MIME)) return false;
188
189
  return prefersFlightRepresentation(parseAcceptTypes(accept));
189
190
  }
@@ -206,7 +207,7 @@ export function mayNeedSSR(request: Request, url: URL): boolean {
206
207
  if (
207
208
  url.searchParams.has("_rsc_partial") ||
208
209
  url.searchParams.has("_rsc_action") ||
209
- request.headers.has("rsc-action") ||
210
+ requestHeaders(request).has("rsc-action") ||
210
211
  url.searchParams.has("_rsc_loader") ||
211
212
  url.searchParams.has("__rsc")
212
213
  ) {
package/src/rsc/types.ts CHANGED
@@ -34,6 +34,8 @@ export interface RscPayload {
34
34
  resolvedIds?: string[];
35
35
  /** Merged route params from the matched route */
36
36
  params?: Record<string, string>;
37
+ /** Matched route name, when named (transition({ when }) route names). */
38
+ routeName?: string;
37
39
  slots?: Record<string, SlotState>;
38
40
  /**
39
41
  * Intercept TARGET route names reachable from this location as a
@@ -228,6 +230,13 @@ export interface SSRModule {
228
230
  origin?: string;
229
231
  /** Each component error the prerender reports (never the capture's own abort). */
230
232
  onError?: (error: unknown) => void;
233
+ /**
234
+ * React's errorInfo for each task still pending at the capture's abort
235
+ * (its componentStack is computed when read).
236
+ */
237
+ onAbortedTask?: (
238
+ errorInfo: { componentStack?: string } | undefined,
239
+ ) => void;
231
240
  },
232
241
  ) => Promise<{ prelude: Uint8Array; postponed: string | null } | null>;
233
242
 
@@ -125,6 +125,14 @@ export interface RenderSegmentsOptions {
125
125
  /** Seeded descendant client-route pending state for testing. */
126
126
  outletPending?: boolean;
127
127
 
128
+ /**
129
+ * This commit's transition({ when }) decision was false
130
+ * (browser/transition-when.ts): the navigation is urgent, so no segment
131
+ * animates. Every router <ViewTransition> stays in the tree with each class
132
+ * "none", so the element type never changes; keys never read this (#995).
133
+ */
134
+ transitionGatedOff?: boolean;
135
+
128
136
  /**
129
137
  * Intercept segments to inject into the tree.
130
138
  * These are parallel segments from intercept routes that need to be
@@ -143,13 +151,31 @@ export interface RenderSegmentsOptions {
143
151
  rootLayout?: ComponentType<RootLayoutProps>;
144
152
  }
145
153
 
154
+ /**
155
+ * On a gated-off commit every segment keeps its <ViewTransition> so the
156
+ * element type never changes with a per-navigation `transition({ when })`
157
+ * decision (#995), but animates nothing: "none" for every class, whatever the
158
+ * transition type.
159
+ */
160
+ const GATED_OFF_CLASSES = {
161
+ enter: "none",
162
+ exit: "none",
163
+ update: "none",
164
+ share: "none",
165
+ default: "none",
166
+ } as const;
167
+
146
168
  function createViewTransitionBoundary(
147
169
  transition: NonNullable<ResolvedSegment["transition"]>,
148
170
  children: ReactNode,
149
171
  ): ReactNode {
150
- // `viewTransition` is a router-specific flag (boundary opt-out), not a React
151
- // <ViewTransition> prop — strip it so it never reaches React.
152
- const { viewTransition: _viewTransition, ...vtProps } = transition;
172
+ // `viewTransition` (boundary opt-out) and `when` (the browser-run gate) are
173
+ // router fields, not React <ViewTransition> props — strip them.
174
+ const {
175
+ viewTransition: _viewTransition,
176
+ when: _when,
177
+ ...vtProps
178
+ } = transition;
153
179
  return createElement(ReactViewTransition, {
154
180
  ...vtProps,
155
181
  // The commit after an optimistic clientUrls() presentation repaints the
@@ -306,6 +332,7 @@ export async function renderSegments(
306
332
  forceAwait,
307
333
  outletPending = false,
308
334
  rootLayout: RootLayout,
335
+ transitionGatedOff,
309
336
  } = options || {};
310
337
 
311
338
  const segDebug = INTERNAL_RANGO_DEBUG;
@@ -358,8 +385,11 @@ export async function renderSegments(
358
385
  // same-route navigation reconciles (holds content) instead of remounting. The
359
386
  // value is a static property of the route's position in the tree, so it is the
360
387
  // same on every render of that route (SSR, navigation, action) — the keys
361
- // never drift. Cross-route navigation still remounts: different routes have
362
- // different segment ids regardless of transition scope.
388
+ // never drift. It reads the static `transition` config only, never the
389
+ // per-navigation `when` decision (transitionGatedOff): a server-side `when`
390
+ // used to delete `transition`, which flipped this value and remounted the
391
+ // route twice (#995). Cross-route navigation still remounts: different routes
392
+ // have different segment ids regardless of transition scope.
363
393
  const inTransitionScope = normalizedSegments.some(
364
394
  (s) =>
365
395
  s.transition != null &&
@@ -522,10 +552,13 @@ export async function renderSegments(
522
552
  transition &&
523
553
  transition.viewTransition !== false
524
554
  ) {
555
+ const boundary = transitionGatedOff
556
+ ? { ...transition, ...GATED_OFF_CLASSES }
557
+ : transition;
525
558
  if (node.segment.type === "layout") {
526
- outletContent = wrapDefaultOutletContent(outletContent, transition);
559
+ outletContent = wrapDefaultOutletContent(outletContent, boundary);
527
560
  } else {
528
- nodeContent = createViewTransitionBoundary(transition, nodeContent);
561
+ nodeContent = createViewTransitionBoundary(boundary, nodeContent);
529
562
  }
530
563
  }
531
564