@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1c0bdfad

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 (406) hide show
  1. package/AGENTS.md +17 -0
  2. package/README.md +291 -61
  3. package/dist/bin/rango.js +544 -143
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3744 -1329
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +67 -13
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +312 -0
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +247 -23
  12. package/skills/caching/SKILL.md +322 -19
  13. package/skills/composability/SKILL.md +27 -2
  14. package/skills/css/SKILL.md +76 -0
  15. package/skills/debug-manifest/SKILL.md +4 -2
  16. package/skills/document-cache/SKILL.md +78 -55
  17. package/skills/handler-use/SKILL.md +364 -0
  18. package/skills/hooks/SKILL.md +282 -60
  19. package/skills/host-router/SKILL.md +278 -0
  20. package/skills/i18n/SKILL.md +276 -0
  21. package/skills/intercept/SKILL.md +50 -6
  22. package/skills/layout/SKILL.md +35 -9
  23. package/skills/links/SKILL.md +249 -17
  24. package/skills/loader/SKILL.md +297 -31
  25. package/skills/middleware/SKILL.md +52 -13
  26. package/skills/migrate-nextjs/SKILL.md +584 -0
  27. package/skills/migrate-react-router/SKILL.md +771 -0
  28. package/skills/mime-routes/SKILL.md +28 -1
  29. package/skills/observability/SKILL.md +172 -0
  30. package/skills/parallel/SKILL.md +203 -7
  31. package/skills/prerender/SKILL.md +155 -111
  32. package/skills/rango/SKILL.md +251 -23
  33. package/skills/react-compiler/SKILL.md +168 -0
  34. package/skills/response-routes/SKILL.md +123 -48
  35. package/skills/route/SKILL.md +104 -9
  36. package/skills/router-setup/SKILL.md +124 -11
  37. package/skills/scripts/SKILL.md +179 -0
  38. package/skills/server-actions/SKILL.md +775 -0
  39. package/skills/streams-and-websockets/SKILL.md +283 -0
  40. package/skills/tailwind/SKILL.md +27 -3
  41. package/skills/testing/SKILL.md +125 -222
  42. package/skills/testing/bindings.md +103 -0
  43. package/skills/testing/cache-prerender.md +127 -0
  44. package/skills/testing/client-components.md +124 -0
  45. package/skills/testing/e2e-parity.md +125 -0
  46. package/skills/testing/flight.md +91 -0
  47. package/skills/testing/handles.md +129 -0
  48. package/skills/testing/loader.md +128 -0
  49. package/skills/testing/middleware.md +99 -0
  50. package/skills/testing/render-handler.md +121 -0
  51. package/skills/testing/response-routes.md +95 -0
  52. package/skills/testing/reverse-and-types.md +84 -0
  53. package/skills/testing/server-actions.md +107 -0
  54. package/skills/testing/server-tree.md +128 -0
  55. package/skills/testing/setup.md +123 -0
  56. package/skills/typesafety/SKILL.md +357 -52
  57. package/skills/use-cache/SKILL.md +46 -14
  58. package/skills/view-transitions/SKILL.md +294 -0
  59. package/src/__augment-tests__/augment.ts +81 -0
  60. package/src/__augment-tests__/augmented.check.ts +116 -0
  61. package/src/__internal.ts +67 -40
  62. package/src/bin/rango.ts +18 -0
  63. package/src/browser/action-coordinator.ts +53 -36
  64. package/src/browser/action-fence.ts +47 -0
  65. package/src/browser/app-shell.ts +39 -0
  66. package/src/browser/app-version.ts +14 -0
  67. package/src/browser/connection-warmup.ts +134 -0
  68. package/src/browser/cookie-name.ts +140 -0
  69. package/src/browser/event-controller.ts +197 -150
  70. package/src/browser/history-state.ts +21 -0
  71. package/src/browser/index.ts +3 -3
  72. package/src/browser/invalidate-client-cache.ts +52 -0
  73. package/src/browser/link-interceptor.ts +4 -0
  74. package/src/browser/navigation-bridge.ts +200 -30
  75. package/src/browser/navigation-client.ts +217 -58
  76. package/src/browser/navigation-store-handle.ts +38 -0
  77. package/src/browser/navigation-store.ts +76 -67
  78. package/src/browser/navigation-transaction.ts +18 -66
  79. package/src/browser/network-error-handler.ts +34 -7
  80. package/src/browser/partial-update.ts +187 -112
  81. package/src/browser/prefetch/cache.ts +312 -30
  82. package/src/browser/prefetch/fetch.ts +344 -47
  83. package/src/browser/prefetch/policy.ts +6 -0
  84. package/src/browser/prefetch/queue.ts +126 -20
  85. package/src/browser/prefetch/resource-ready.ts +77 -0
  86. package/src/browser/rango-state.ts +158 -76
  87. package/src/browser/react/Link.tsx +125 -18
  88. package/src/browser/react/NavigationProvider.tsx +135 -120
  89. package/src/browser/react/ScrollRestoration.tsx +10 -6
  90. package/src/browser/react/context.ts +7 -2
  91. package/src/browser/react/filter-segment-order.ts +66 -7
  92. package/src/browser/react/index.ts +0 -48
  93. package/src/browser/react/location-state-shared.ts +178 -8
  94. package/src/browser/react/location-state.ts +39 -14
  95. package/src/browser/react/use-action.ts +6 -15
  96. package/src/browser/react/use-handle.ts +23 -69
  97. package/src/browser/react/use-href.tsx +8 -1
  98. package/src/browser/react/use-link-status.ts +33 -8
  99. package/src/browser/react/use-navigation.ts +32 -7
  100. package/src/browser/react/use-params.ts +20 -10
  101. package/src/browser/react/use-reverse.ts +106 -0
  102. package/src/browser/react/use-router.ts +46 -11
  103. package/src/browser/react/use-search-params.ts +0 -5
  104. package/src/browser/react/use-segments.ts +11 -21
  105. package/src/browser/response-adapter.ts +80 -5
  106. package/src/browser/rsc-router.tsx +226 -75
  107. package/src/browser/scroll-restoration.ts +54 -42
  108. package/src/browser/segment-reconciler.ts +36 -9
  109. package/src/browser/segment-structure-assert.ts +2 -2
  110. package/src/browser/server-action-bridge.ts +619 -442
  111. package/src/browser/types.ts +115 -11
  112. package/src/browser/validate-redirect-origin.ts +43 -16
  113. package/src/build/collect-fallback-refs.ts +107 -0
  114. package/src/build/generate-manifest.ts +65 -40
  115. package/src/build/generate-route-types.ts +7 -1
  116. package/src/build/index.ts +8 -2
  117. package/src/build/prefix-tree-utils.ts +123 -0
  118. package/src/build/route-trie.ts +182 -37
  119. package/src/build/route-types/ast-route-extraction.ts +15 -8
  120. package/src/build/route-types/codegen.ts +16 -5
  121. package/src/build/route-types/include-resolution.ts +125 -24
  122. package/src/build/route-types/param-extraction.ts +6 -3
  123. package/src/build/route-types/per-module-writer.ts +22 -6
  124. package/src/build/route-types/router-processing.ts +392 -106
  125. package/src/build/route-types/scan-filter.ts +9 -2
  126. package/src/build/route-types/source-scan.ts +216 -0
  127. package/src/build/runtime-discovery.ts +9 -20
  128. package/src/cache/cache-error.ts +104 -0
  129. package/src/cache/cache-key-utils.ts +29 -13
  130. package/src/cache/cache-policy.ts +108 -34
  131. package/src/cache/cache-runtime.ts +214 -48
  132. package/src/cache/cache-scope.ts +236 -89
  133. package/src/cache/cache-tag.ts +103 -0
  134. package/src/cache/cf/cf-base64.ts +33 -0
  135. package/src/cache/cf/cf-cache-constants.ts +127 -0
  136. package/src/cache/cf/cf-cache-store.ts +2224 -171
  137. package/src/cache/cf/cf-cache-types.ts +349 -0
  138. package/src/cache/cf/cf-kv-utils.ts +46 -0
  139. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  140. package/src/cache/cf/index.ts +11 -17
  141. package/src/cache/document-cache.ts +89 -27
  142. package/src/cache/handle-snapshot.ts +70 -0
  143. package/src/cache/index.ts +11 -20
  144. package/src/cache/memory-segment-store.ts +136 -37
  145. package/src/cache/profile-registry.ts +31 -31
  146. package/src/cache/read-through-swr.ts +41 -11
  147. package/src/cache/segment-codec.ts +9 -17
  148. package/src/cache/tag-invalidation.ts +230 -0
  149. package/src/cache/taint.ts +55 -0
  150. package/src/cache/types.ts +37 -100
  151. package/src/client.rsc.tsx +45 -21
  152. package/src/client.tsx +120 -336
  153. package/src/cloudflare/index.ts +11 -0
  154. package/src/cloudflare/tracing.ts +109 -0
  155. package/src/component-utils.ts +19 -0
  156. package/src/components/DefaultDocument.tsx +8 -2
  157. package/src/context-var.ts +84 -2
  158. package/src/debug.ts +2 -2
  159. package/src/decode-loader-results.ts +52 -0
  160. package/src/defer.ts +196 -0
  161. package/src/deps/ssr.ts +0 -1
  162. package/src/encode-kv.ts +49 -0
  163. package/src/errors.ts +30 -4
  164. package/src/escape-script.ts +52 -0
  165. package/src/handle.ts +70 -22
  166. package/src/handles/MetaTags.tsx +56 -19
  167. package/src/handles/Scripts.tsx +183 -0
  168. package/src/handles/breadcrumbs.ts +95 -0
  169. package/src/handles/is-thenable.ts +19 -0
  170. package/src/handles/meta.ts +51 -40
  171. package/src/handles/script.ts +244 -0
  172. package/src/host/cookie-handler.ts +9 -60
  173. package/src/host/errors.ts +0 -24
  174. package/src/host/index.ts +8 -5
  175. package/src/host/pattern-matcher.ts +23 -52
  176. package/src/host/router.ts +107 -99
  177. package/src/host/testing.ts +40 -27
  178. package/src/host/types.ts +37 -4
  179. package/src/host/utils.ts +1 -1
  180. package/src/href-client.ts +137 -22
  181. package/src/index.rsc.ts +79 -29
  182. package/src/index.ts +149 -65
  183. package/src/internal-debug.ts +11 -10
  184. package/src/loader-store.ts +500 -0
  185. package/src/loader.rsc.ts +20 -13
  186. package/src/loader.ts +12 -11
  187. package/src/missing-id-error.ts +68 -0
  188. package/src/outlet-context.ts +1 -1
  189. package/src/outlet-provider.tsx +1 -5
  190. package/src/prerender/param-hash.ts +16 -16
  191. package/src/prerender/store.ts +63 -26
  192. package/src/prerender.ts +198 -82
  193. package/src/redirect-origin.ts +100 -0
  194. package/src/regex-escape.ts +8 -0
  195. package/src/render-error-thrower.tsx +20 -0
  196. package/src/response-utils.ts +62 -0
  197. package/src/reverse.ts +65 -15
  198. package/src/root-error-boundary.tsx +1 -19
  199. package/src/route-content-wrapper.tsx +7 -72
  200. package/src/route-definition/dsl-helpers.ts +469 -276
  201. package/src/route-definition/helper-factories.ts +29 -139
  202. package/src/route-definition/helpers-types.ts +113 -37
  203. package/src/route-definition/index.ts +3 -3
  204. package/src/route-definition/redirect.ts +53 -12
  205. package/src/route-definition/resolve-handler-use.ts +161 -0
  206. package/src/route-definition/use-item-types.ts +32 -0
  207. package/src/route-map-builder.ts +7 -17
  208. package/src/route-types.ts +37 -41
  209. package/src/router/basename.ts +14 -0
  210. package/src/router/content-negotiation.ts +164 -17
  211. package/src/router/error-handling.ts +45 -18
  212. package/src/router/find-match.ts +45 -22
  213. package/src/router/handler-context.ts +110 -39
  214. package/src/router/instrument.ts +350 -0
  215. package/src/router/intercept-resolution.ts +50 -24
  216. package/src/router/lazy-includes.ts +19 -53
  217. package/src/router/loader-resolution.ts +274 -56
  218. package/src/router/logging.ts +5 -8
  219. package/src/router/manifest.ts +49 -45
  220. package/src/router/match-api.ts +121 -205
  221. package/src/router/match-context.ts +0 -22
  222. package/src/router/match-handlers.ts +58 -58
  223. package/src/router/match-middleware/background-revalidation.ts +33 -6
  224. package/src/router/match-middleware/cache-lookup.ts +214 -263
  225. package/src/router/match-middleware/cache-store.ts +73 -33
  226. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  227. package/src/router/match-middleware/segment-resolution.ts +52 -18
  228. package/src/router/match-pipelines.ts +1 -42
  229. package/src/router/match-result.ts +104 -49
  230. package/src/router/metrics.ts +217 -26
  231. package/src/router/middleware-types.ts +24 -110
  232. package/src/router/middleware.ts +384 -197
  233. package/src/router/navigation-snapshot.ts +131 -0
  234. package/src/router/params-util.ts +23 -0
  235. package/src/router/pattern-matching.ts +148 -91
  236. package/src/router/prefetch-cache-ttl.ts +51 -0
  237. package/src/router/prerender-match.ts +199 -56
  238. package/src/router/preview-match.ts +32 -102
  239. package/src/router/request-classification.ts +276 -0
  240. package/src/router/revalidation.ts +144 -74
  241. package/src/router/route-snapshot.ts +244 -0
  242. package/src/router/router-context.ts +8 -28
  243. package/src/router/router-interfaces.ts +129 -36
  244. package/src/router/router-options.ts +185 -23
  245. package/src/router/router-registry.ts +2 -5
  246. package/src/router/segment-resolution/fresh.ts +281 -76
  247. package/src/router/segment-resolution/helpers.ts +116 -31
  248. package/src/router/segment-resolution/loader-cache.ts +63 -37
  249. package/src/router/segment-resolution/revalidation.ts +493 -391
  250. package/src/router/segment-resolution/static-store.ts +19 -5
  251. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  252. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  253. package/src/router/segment-resolution.ts +5 -1
  254. package/src/router/segment-wrappers.ts +8 -5
  255. package/src/router/state-cookie-name.ts +33 -0
  256. package/src/router/substitute-pattern-params.ts +56 -0
  257. package/src/router/telemetry-otel.ts +161 -199
  258. package/src/router/telemetry.ts +96 -19
  259. package/src/router/timeout.ts +0 -20
  260. package/src/router/tracing.ts +206 -0
  261. package/src/router/trie-matching.ts +180 -58
  262. package/src/router/types.ts +10 -63
  263. package/src/router/url-params.ts +44 -0
  264. package/src/router.ts +182 -54
  265. package/src/rsc/handler-context.ts +3 -2
  266. package/src/rsc/handler.ts +702 -460
  267. package/src/rsc/helpers.ts +168 -46
  268. package/src/rsc/index.ts +2 -25
  269. package/src/rsc/json-route-result.ts +38 -0
  270. package/src/rsc/loader-fetch.ts +127 -31
  271. package/src/rsc/manifest-init.ts +33 -42
  272. package/src/rsc/origin-guard.ts +39 -25
  273. package/src/rsc/progressive-enhancement.ts +98 -19
  274. package/src/rsc/redirect-guard.ts +99 -0
  275. package/src/rsc/response-cache-serve.ts +238 -0
  276. package/src/rsc/response-error.ts +79 -12
  277. package/src/rsc/response-route-handler.ts +99 -189
  278. package/src/rsc/rsc-rendering.ts +126 -106
  279. package/src/rsc/runtime-warnings.ts +23 -10
  280. package/src/rsc/server-action.ts +269 -114
  281. package/src/rsc/ssr-setup.ts +144 -0
  282. package/src/rsc/types.ts +34 -6
  283. package/src/runtime-env.ts +18 -0
  284. package/src/search-params.ts +49 -41
  285. package/src/segment-content-promise.ts +67 -0
  286. package/src/segment-loader-promise.ts +149 -0
  287. package/src/segment-system.tsx +281 -129
  288. package/src/serialize.ts +243 -0
  289. package/src/server/context.ts +317 -63
  290. package/src/server/cookie-parse.ts +32 -0
  291. package/src/server/cookie-store.ts +80 -5
  292. package/src/server/handle-store.ts +40 -38
  293. package/src/server/loader-registry.ts +26 -46
  294. package/src/server/request-context.ts +425 -177
  295. package/src/server.ts +6 -0
  296. package/src/ssr/index.tsx +25 -16
  297. package/src/static-handler.ts +27 -18
  298. package/src/testing/cache-status.ts +162 -0
  299. package/src/testing/collect-handle.ts +40 -0
  300. package/src/testing/dispatch.ts +701 -0
  301. package/src/testing/dom.entry.ts +22 -0
  302. package/src/testing/e2e/fixture.ts +188 -0
  303. package/src/testing/e2e/index.ts +128 -0
  304. package/src/testing/e2e/matchers.ts +35 -0
  305. package/src/testing/e2e/page-helpers.ts +272 -0
  306. package/src/testing/e2e/parity.ts +387 -0
  307. package/src/testing/e2e/server.ts +195 -0
  308. package/src/testing/flight-matchers.ts +97 -0
  309. package/src/testing/flight-normalize.ts +11 -0
  310. package/src/testing/flight-runtime.d.ts +57 -0
  311. package/src/testing/flight-tree.ts +682 -0
  312. package/src/testing/flight.entry.ts +52 -0
  313. package/src/testing/flight.ts +257 -0
  314. package/src/testing/generated-routes.ts +183 -0
  315. package/src/testing/index.ts +99 -0
  316. package/src/testing/internal/context.ts +371 -0
  317. package/src/testing/internal/flight-client-globals.ts +30 -0
  318. package/src/testing/internal/seed-vars.ts +54 -0
  319. package/src/testing/render-handler.ts +343 -0
  320. package/src/testing/render-route.tsx +581 -0
  321. package/src/testing/run-loader.ts +385 -0
  322. package/src/testing/run-middleware.ts +205 -0
  323. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  324. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  325. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  326. package/src/testing/vitest-stubs/version.ts +5 -0
  327. package/src/testing/vitest.ts +305 -0
  328. package/src/theme/ThemeProvider.tsx +20 -58
  329. package/src/theme/ThemeScript.tsx +7 -9
  330. package/src/theme/constants.ts +52 -13
  331. package/src/theme/index.ts +3 -19
  332. package/src/theme/theme-context.ts +1 -5
  333. package/src/theme/theme-script.ts +22 -21
  334. package/src/theme/use-theme.ts +0 -3
  335. package/src/types/boundaries.ts +0 -35
  336. package/src/types/cache-types.ts +17 -8
  337. package/src/types/error-types.ts +30 -90
  338. package/src/types/global-namespace.ts +54 -41
  339. package/src/types/handler-context.ts +236 -88
  340. package/src/types/index.ts +1 -10
  341. package/src/types/loader-types.ts +44 -15
  342. package/src/types/request-scope.ts +112 -0
  343. package/src/types/route-config.ts +10 -45
  344. package/src/types/route-entry.ts +19 -7
  345. package/src/types/segments.ts +37 -19
  346. package/src/urls/include-helper.ts +33 -70
  347. package/src/urls/index.ts +1 -11
  348. package/src/urls/path-helper-types.ts +58 -11
  349. package/src/urls/path-helper.ts +57 -111
  350. package/src/urls/pattern-types.ts +48 -19
  351. package/src/urls/response-types.ts +25 -22
  352. package/src/urls/type-extraction.ts +58 -139
  353. package/src/urls/urls-function.ts +1 -18
  354. package/src/use-loader.tsx +346 -89
  355. package/src/vite/debug.ts +185 -0
  356. package/src/vite/discovery/bundle-postprocess.ts +64 -91
  357. package/src/vite/discovery/discover-routers.ts +147 -88
  358. package/src/vite/discovery/discovery-errors.ts +194 -0
  359. package/src/vite/discovery/gate-state.ts +171 -0
  360. package/src/vite/discovery/prerender-collection.ts +247 -145
  361. package/src/vite/discovery/route-types-writer.ts +40 -84
  362. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  363. package/src/vite/discovery/state.ts +61 -13
  364. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  365. package/src/vite/index.ts +10 -3
  366. package/src/vite/inject-client-debug.ts +36 -0
  367. package/src/vite/plugin-types.ts +155 -65
  368. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  369. package/src/vite/plugins/client-ref-dedup.ts +120 -0
  370. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  371. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  372. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  373. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  374. package/src/vite/plugins/expose-action-id.ts +49 -98
  375. package/src/vite/plugins/expose-id-utils.ts +96 -51
  376. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  377. package/src/vite/plugins/expose-ids/handler-transform.ts +15 -64
  378. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  379. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  380. package/src/vite/plugins/expose-internal-ids.ts +553 -317
  381. package/src/vite/plugins/performance-tracks.ts +89 -0
  382. package/src/vite/plugins/refresh-cmd.ts +127 -0
  383. package/src/vite/plugins/use-cache-transform.ts +73 -83
  384. package/src/vite/plugins/version-injector.ts +21 -25
  385. package/src/vite/plugins/version-plugin.ts +46 -37
  386. package/src/vite/plugins/virtual-entries.ts +13 -18
  387. package/src/vite/rango.ts +241 -287
  388. package/src/vite/router-discovery.ts +956 -149
  389. package/src/vite/utils/ast-handler-extract.ts +26 -35
  390. package/src/vite/utils/banner.ts +4 -4
  391. package/src/vite/utils/bundle-analysis.ts +10 -15
  392. package/src/vite/utils/client-chunks.ts +184 -0
  393. package/src/vite/utils/directive-prologue.ts +40 -0
  394. package/src/vite/utils/forward-user-plugins.ts +171 -0
  395. package/src/vite/utils/manifest-utils.ts +4 -59
  396. package/src/vite/utils/package-resolution.ts +20 -52
  397. package/src/vite/utils/prerender-utils.ts +141 -34
  398. package/src/vite/utils/shared-utils.ts +92 -42
  399. package/CLAUDE.md +0 -5
  400. package/src/browser/action-response-classifier.ts +0 -99
  401. package/src/browser/react/use-client-cache.ts +0 -58
  402. package/src/browser/shallow.ts +0 -40
  403. package/src/handles/index.ts +0 -6
  404. package/src/network-error-thrower.tsx +0 -23
  405. package/src/route-definition/route-function.ts +0 -119
  406. package/src/router/middleware-cookies.ts +0 -55
@@ -8,6 +8,7 @@ import {
8
8
  generateHistoryKey,
9
9
  } from "./navigation-store.js";
10
10
  import { createEventController } from "./event-controller.js";
11
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
11
12
  import { createNavigationClient } from "./navigation-client.js";
12
13
  import { createServerActionBridge } from "./server-action-bridge.js";
13
14
  import { createNavigationBridge } from "./navigation-bridge.js";
@@ -22,10 +23,15 @@ import type {
22
23
  import type { EventController } from "./event-controller.js";
23
24
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
25
  import { initRangoState } from "./rango-state.js";
26
+ import { registerNavigationStore } from "./navigation-store-handle.js";
27
+ import { initPrefetchCache } from "./prefetch/cache.js";
28
+ import { setPrefetchDecoder } from "./prefetch/fetch.js";
29
+ import { setAppVersion } from "./app-version.js";
25
30
  import {
26
31
  isInterceptSegment,
27
32
  splitInterceptSegments,
28
33
  } from "./intercept-utils.js";
34
+ import { createAppShellRef } from "./app-shell.js";
29
35
 
30
36
  // Vite HMR types are provided by vite/client
31
37
 
@@ -110,15 +116,26 @@ export interface BrowserAppContext {
110
116
  initialTheme?: Theme;
111
117
  /** Whether connection warmup is enabled */
112
118
  warmupEnabled?: boolean;
119
+ /** Whether the hydrated tree should be wrapped in React.StrictMode */
120
+ strictMode?: boolean;
113
121
  /** App version for prefetch version mismatch detection */
114
122
  version?: string;
123
+ /**
124
+ * App-shell ref, read through on each render so renderSegments and the
125
+ * NavigationProvider see rootLayout/basename/version without closing over a
126
+ * stale snapshot. Set once from the initial payload and not swapped within a
127
+ * session: a cross-app navigation is a full document load (X-RSC-Reload), so
128
+ * the target app establishes its own shell on load. Theme, warmup, and
129
+ * prefetch TTL are document-lifetime too (see AppShell).
130
+ */
131
+ appShellRef?: import("./app-shell.js").AppShellRef;
115
132
  }
116
133
 
117
134
  // Module-level state for the initialized app
118
135
  let browserAppContext: BrowserAppContext | null = null;
119
136
 
120
137
  /**
121
- * Initialize the browser app. Must be called before rendering RSCRouter.
138
+ * Initialize the browser app. Must be called before rendering Rango.
122
139
  *
123
140
  * This function:
124
141
  * - Loads the initial RSC payload from the stream
@@ -138,7 +155,6 @@ export async function initBrowserApp(
138
155
  initialTheme,
139
156
  } = options;
140
157
 
141
- // Load initial payload from SSR-injected __FLIGHT_DATA__
142
158
  const initialPayload =
143
159
  await deps.createFromReadableStream<RscPayload>(rscStream);
144
160
 
@@ -163,6 +179,18 @@ export async function initBrowserApp(
163
179
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
164
180
  });
165
181
 
182
+ // Register the active store on the module-level handle and wire the
183
+ // jar-divergence observer before any getRangoState() read can detect a
184
+ // cross-tab/server rotation. There is no global store singleton, so this
185
+ // handle is the live reference.
186
+ registerNavigationStore(store);
187
+
188
+ // Seed router identity from the initial SSR payload so the first
189
+ // cross-app SPA navigation can detect the app switch.
190
+ if (initialPayload.metadata?.routerId) {
191
+ store.setRouterId?.(initialPayload.metadata.routerId);
192
+ }
193
+
166
194
  // Create event controller for reactive state management
167
195
  const eventController = createEventController({
168
196
  initialLocation: new URL(window.location.href),
@@ -197,19 +225,49 @@ export async function initBrowserApp(
197
225
  // Create composable utilities
198
226
  const client = createNavigationClient(deps);
199
227
 
200
- // Extract rootLayout and version from metadata for browser-side re-renders
201
- const rootLayout = initialPayload.metadata?.rootLayout;
228
+ // Capture the per-router app-shell. rootLayout, basename, and version live
229
+ // here and are read through the ref at call time rather than closed over.
230
+ // It is set once from the initial payload and not swapped within a session:
231
+ // a cross-app navigation is a full document load (X-RSC-Reload), so the
232
+ // target app establishes its own shell on load.
202
233
  const version = initialPayload.metadata?.version;
234
+ const appShellRef = createAppShellRef({
235
+ routerId: initialPayload.metadata?.routerId,
236
+ rootLayout: initialPayload.metadata?.rootLayout,
237
+ basename: initialPayload.metadata?.basename,
238
+ version,
239
+ });
203
240
 
204
- // Initialize the localStorage state key for browser HTTP cache invalidation.
205
- // Uses the build version so a new deploy automatically busts all cached prefetches.
206
- initRangoState(version ?? "0");
241
+ // Initialize the rango state cookie for cache invalidation. The build version
242
+ // busts cached prefetches on deploy; the server-resolved cookie name
243
+ // namespaces the cookie so sibling apps on the same origin don't collide
244
+ // (falls back to the bare default prefix if metadata lacks the name).
245
+ initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
246
+ setAppVersion(version);
247
+
248
+ // Initialize the in-memory prefetch cache TTL from server config.
249
+ // A value of 0 disables the cache; undefined falls back to the module default.
250
+ const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
251
+ if (prefetchCacheTTL !== undefined) {
252
+ initPrefetchCache(prefetchCacheTTL);
253
+ }
207
254
 
208
- // Create a bound renderSegments that includes rootLayout
255
+ // Wire the RSC decoder so prefetches decode eagerly and warm the route's
256
+ // client chunks (same createFromFetch the navigation client uses).
257
+ setPrefetchDecoder((response) => deps.createFromFetch<RscPayload>(response));
258
+
259
+ // Create a bound renderSegments that reads rootLayout through the shell ref.
260
+ // The shell is set once at init and not swapped within a session (a cross-app
261
+ // navigation is a full document load), so this always renders this app's
262
+ // Document; reading through the ref just avoids closing over a stale value.
209
263
  const renderSegments = (
210
264
  segments: ResolvedSegment[],
211
265
  options?: RenderSegmentsOptions,
212
- ) => baseRenderSegments(segments, { ...options, rootLayout });
266
+ ) =>
267
+ baseRenderSegments(segments, {
268
+ ...options,
269
+ rootLayout: appShellRef.get().rootLayout,
270
+ });
213
271
 
214
272
  // Lazy reference for navigation bridge — the action bridge is created first
215
273
  // but may need to trigger SPA navigation for action redirects.
@@ -223,10 +281,15 @@ export async function initBrowserApp(
223
281
  deps,
224
282
  onUpdate: (update) => store.emitUpdate(update),
225
283
  renderSegments,
226
- version,
227
284
  onNavigate: (url, options) => {
228
285
  if (!navigateFn) {
229
- window.location.href = url;
286
+ // Navigation bridge not wired yet: hard-navigate, but re-validate
287
+ // same-origin defensively so this init-window fallback cannot become an
288
+ // open redirect (the normal path validates inside the navigation bridge).
289
+ const safe = validateRedirectOrigin(url, window.location.origin);
290
+ if (safe) {
291
+ window.location.href = safe;
292
+ }
230
293
  return Promise.resolve();
231
294
  }
232
295
  return navigateFn(url, options);
@@ -241,7 +304,7 @@ export async function initBrowserApp(
241
304
  client,
242
305
  onUpdate: (update) => store.emitUpdate(update),
243
306
  renderSegments,
244
- version,
307
+ version: version,
245
308
  });
246
309
 
247
310
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -255,74 +318,157 @@ export async function initBrowserApp(
255
318
  // Build initial tree with rootLayout
256
319
  const initialTree = renderSegments(initialPayload.metadata!.segments);
257
320
 
258
- // Setup HMR
321
+ // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
322
+ // fire many rsc:update events in quick succession. Without debouncing,
323
+ // each event triggers a fetchPartial() which on slow routes can pile up
324
+ // and overwhelm the worker (cross-request promise issues, 500s).
259
325
  if (import.meta.hot) {
260
- import.meta.hot.on("rsc:update", async () => {
261
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
262
-
263
- using handle = eventController.startNavigation(window.location.href, {
264
- replace: true,
265
- });
266
- const streamingToken = handle.startStreaming();
267
-
268
- const interceptSourceUrl = store.getInterceptSourceUrl();
269
-
270
- try {
271
- const { payload, streamComplete } = await client.fetchPartial({
272
- targetUrl: window.location.href,
273
- segmentIds: [],
274
- previousUrl: store.getSegmentState().currentUrl,
275
- interceptSourceUrl: interceptSourceUrl || undefined,
276
- hmr: true,
326
+ let hmrTimer: ReturnType<typeof setTimeout> | null = null;
327
+ let hmrAbort: AbortController | null = null;
328
+
329
+ import.meta.hot.on("rsc:update", () => {
330
+ // Cancel any pending debounce timer
331
+ if (hmrTimer !== null) {
332
+ clearTimeout(hmrTimer);
333
+ }
334
+
335
+ // Abort any in-flight HMR fetch so it doesn't race with the next one
336
+ if (hmrAbort) {
337
+ hmrAbort.abort();
338
+ hmrAbort = null;
339
+ }
340
+
341
+ // Debounce: wait 200ms of quiet before fetching
342
+ hmrTimer = setTimeout(async () => {
343
+ hmrTimer = null;
344
+
345
+ // Don't interrupt an active user navigation — startNavigation()
346
+ // would abort it and refetch the old URL (window.location.href
347
+ // hasn't updated yet). The user's navigation will pick up the
348
+ // new server code when it completes. isNavigating covers the
349
+ // full lifecycle (fetching + streaming, before commit) without
350
+ // blocking on server actions.
351
+ if (eventController.getState().isNavigating) {
352
+ console.log("[Rango] HMR: Skipping — navigation in progress");
353
+ return;
354
+ }
355
+
356
+ console.log("[Rango] HMR: Server update, refetching RSC");
357
+
358
+ const abort = new AbortController();
359
+ hmrAbort = abort;
360
+
361
+ const handle = eventController.startNavigation(window.location.href, {
362
+ replace: true,
277
363
  });
364
+ const streamingToken = handle.startStreaming();
365
+
366
+ const interceptSourceUrl = store.getInterceptSourceUrl();
367
+
368
+ try {
369
+ const { payload, streamComplete } = await client.fetchPartial({
370
+ targetUrl: window.location.href,
371
+ segmentIds: [],
372
+ previousUrl: store.getSegmentState().currentUrl,
373
+ interceptSourceUrl: interceptSourceUrl || undefined,
374
+ routerId: store.getRouterId?.(),
375
+ hmr: true,
376
+ signal: abort.signal,
377
+ });
278
378
 
279
- if (payload.metadata?.isPartial) {
280
- const segments = payload.metadata.segments || [];
281
- const matched = payload.metadata.matched || [];
379
+ if (abort.signal.aborted) return;
282
380
 
283
- // Derive intercept state from the returned payload, not the
284
- // pre-fetch store snapshot. If the HMR edit removed intercept
285
- // behavior, the response won't contain intercept segments.
286
- const responseIsIntercept = segments.some(isInterceptSegment);
381
+ // If the server returned a non-RSC response (404, 500 without
382
+ // error boundary), the payload won't have valid metadata.
383
+ // Reload to recover rather than leaving the page stale.
384
+ if (!payload.metadata) {
385
+ throw new Error("HMR refetch returned invalid payload");
386
+ }
287
387
 
288
- // Sync store intercept state with what the server returned
289
- if (!responseIsIntercept && interceptSourceUrl) {
290
- store.setInterceptSourceUrl(null);
388
+ // Update version BEFORE rebuilding state so that
389
+ // clearHistoryCache() runs first, then the fresh segment
390
+ // cache entry we create below survives.
391
+ //
392
+ // Compare against the bridge's live version, not the init-time
393
+ // `version` const: after the first HMR bump the const is stale, so a
394
+ // later update with an unchanged version would otherwise re-clear the
395
+ // cache and re-broadcast across tabs/apps. The live read fires only
396
+ // on a genuine version change.
397
+ const newVersion = payload.metadata.version;
398
+ const currentVersion = navigationBridge.getVersion();
399
+ if (newVersion && newVersion !== currentVersion) {
400
+ console.log(
401
+ "[Rango] HMR: version changed",
402
+ currentVersion,
403
+ "→",
404
+ newVersion,
405
+ "clearing caches",
406
+ );
407
+ navigationBridge.updateVersion(newVersion);
291
408
  }
292
409
 
293
- store.setSegmentIds(matched);
294
- store.setCurrentUrl(window.location.href);
410
+ // Apply only partial segment updates. A non-partial payload during
411
+ // HMR is transient: the worker route table is still rebuilding after
412
+ // the edit, so the URL momentarily resolves to not-found/catch-all.
413
+ // Skip it -- the debounced follow-up refetch returns the settled
414
+ // route's partial payload and renders it below. We never reload here:
415
+ // a paramless document GET would run the SSR path and surface the
416
+ // not-found page during that same transient.
417
+ if (payload.metadata?.isPartial) {
418
+ const segments = payload.metadata.segments || [];
419
+ const matched = payload.metadata.matched || [];
420
+
421
+ // Derive intercept state from the returned payload, not the
422
+ // pre-fetch store snapshot. If the HMR edit removed intercept
423
+ // behavior, the response won't contain intercept segments.
424
+ const responseIsIntercept = segments.some(isInterceptSegment);
425
+
426
+ // Sync store intercept state with what the server returned
427
+ if (!responseIsIntercept && interceptSourceUrl) {
428
+ store.setInterceptSourceUrl(null);
429
+ }
430
+
431
+ store.setSegmentIds(matched);
432
+ store.setCurrentUrl(window.location.href);
433
+
434
+ const historyKey = generateHistoryKey(window.location.href, {
435
+ intercept: responseIsIntercept,
436
+ });
437
+ store.setHistoryKey(historyKey);
438
+ const currentHandleData = eventController.getHandleState().data;
439
+ store.cacheSegmentsForHistory(
440
+ historyKey,
441
+ segments,
442
+ currentHandleData,
443
+ );
444
+
445
+ const { main, intercept } = splitInterceptSegments(segments);
446
+ store.emitUpdate({
447
+ root: renderSegments(main, {
448
+ interceptSegments: intercept.length > 0 ? intercept : undefined,
449
+ }),
450
+ metadata: payload.metadata,
451
+ });
452
+ }
295
453
 
296
- const historyKey = generateHistoryKey(window.location.href, {
297
- intercept: responseIsIntercept,
298
- });
299
- store.setHistoryKey(historyKey);
300
- const currentHandleData = eventController.getHandleState().data;
301
- store.cacheSegmentsForHistory(
302
- historyKey,
303
- segments,
304
- currentHandleData,
305
- );
306
-
307
- const { main, intercept } = splitInterceptSegments(segments);
308
- store.emitUpdate({
309
- root: renderSegments(main, {
310
- interceptSegments: intercept.length > 0 ? intercept : undefined,
311
- }),
312
- metadata: payload.metadata,
313
- });
454
+ await streamComplete;
455
+ handle.complete(new URL(window.location.href));
456
+ console.log("[Rango] HMR: RSC stream complete");
457
+ } catch (err) {
458
+ if (abort.signal.aborted) return;
459
+ console.warn("[Rango] HMR: Refetch failed, reloading page", err);
460
+ window.location.reload();
461
+ return;
462
+ } finally {
463
+ if (hmrAbort === abort) hmrAbort = null;
464
+ streamingToken.end();
465
+ handle[Symbol.dispose]();
314
466
  }
315
-
316
- await streamComplete;
317
- handle.complete(new URL(window.location.href));
318
- console.log("[RSCRouter] HMR: RSC stream complete");
319
- } finally {
320
- streamingToken.end();
321
- }
467
+ }, 200);
322
468
  });
323
469
  }
324
470
 
325
- // Store context for RSCRouter component
471
+ // Store context for Rango component
326
472
  const context: BrowserAppContext = {
327
473
  store,
328
474
  eventController,
@@ -332,7 +478,9 @@ export async function initBrowserApp(
332
478
  themeConfig: effectiveThemeConfig,
333
479
  initialTheme: effectiveInitialTheme,
334
480
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
481
+ strictMode: initialPayload.metadata?.strictMode ?? true,
335
482
  version,
483
+ appShellRef,
336
484
  };
337
485
  browserAppContext = context;
338
486
 
@@ -345,7 +493,7 @@ export async function initBrowserApp(
345
493
  export function getBrowserAppContext(): BrowserAppContext {
346
494
  if (!browserAppContext) {
347
495
  throw new Error(
348
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
496
+ "Rango: initBrowserApp() must be called before rendering Rango",
349
497
  );
350
498
  }
351
499
  return browserAppContext;
@@ -359,18 +507,18 @@ export function resetBrowserAppContext(): void {
359
507
  }
360
508
 
361
509
  /**
362
- * Props for the RSCRouter component
510
+ * Props for the Rango component
363
511
  */
364
- export interface RSCRouterProps {}
512
+ export interface RangoProps {}
365
513
 
366
514
  /**
367
- * RSCRouter component - renders the RSC router with all internal wiring.
515
+ * Rango component - renders the RSC router with all internal wiring.
368
516
  *
369
517
  * Must be called after initBrowserApp() has completed.
370
518
  *
371
519
  * @example
372
520
  * ```tsx
373
- * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
521
+ * import { initBrowserApp, Rango } from "rsc-router/browser";
374
522
  * import { rscStream } from "rsc-html-stream/client";
375
523
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
376
524
  *
@@ -380,14 +528,14 @@ export interface RSCRouterProps {}
380
528
  * hydrateRoot(
381
529
  * document,
382
530
  * <React.StrictMode>
383
- * <RSCRouter />
531
+ * <Rango />
384
532
  * </React.StrictMode>
385
533
  * );
386
534
  * }
387
535
  * main();
388
536
  * ```
389
537
  */
390
- export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
538
+ export function Rango(_props: RangoProps): React.ReactElement {
391
539
  const {
392
540
  store,
393
541
  eventController,
@@ -398,6 +546,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
398
546
  initialTheme,
399
547
  warmupEnabled,
400
548
  version,
549
+ appShellRef,
401
550
  } = getBrowserAppContext();
402
551
 
403
552
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -417,6 +566,8 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
417
566
  initialTheme={initialTheme}
418
567
  warmupEnabled={warmupEnabled}
419
568
  version={version}
569
+ basename={initialPayload.metadata?.basename}
570
+ appShellRef={appShellRef}
420
571
  />
421
572
  );
422
573
  }
@@ -10,6 +10,15 @@
10
10
 
11
11
  import { debugLog } from "./logging.js";
12
12
 
13
+ /**
14
+ * Defers a callback to the next animation frame.
15
+ * Falls back to setTimeout(0) in environments without requestAnimationFrame.
16
+ */
17
+ const deferToNextPaint: (fn: () => void) => void =
18
+ typeof requestAnimationFrame === "function"
19
+ ? requestAnimationFrame
20
+ : (fn) => setTimeout(fn, 0);
21
+
13
22
  const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
14
23
 
15
24
  /**
@@ -182,10 +191,15 @@ export function saveCurrentScrollPosition(): void {
182
191
 
183
192
  /**
184
193
  * Persist scroll positions to sessionStorage.
185
- * If the write fails due to quota exceeded, progressively evict the oldest
186
- * entries and retry until it succeeds or the store is empty.
194
+ * If the write fails (typically QuotaExceededError), evict the oldest ~1/4 of
195
+ * entries ONCE and retry the write a single time; if that still fails, remove
196
+ * our storage key entirely so we don't block other sessionStorage consumers.
197
+ * This is a single evict-then-retry-then-clear ladder, not a loop.
198
+ *
199
+ * Exported so that single eviction/retry/clear ladder is unit-testable directly.
200
+ * The browser drives it from the `pagehide` handler.
187
201
  */
188
- function persistToSessionStorage(): void {
202
+ export function persistToSessionStorage(): void {
189
203
  try {
190
204
  sessionStorage.setItem(
191
205
  SCROLL_STORAGE_KEY,
@@ -264,51 +278,35 @@ export function restoreScrollPosition(options?: {
264
278
  return false;
265
279
  }
266
280
 
267
- // Check if page is tall enough to scroll to saved position
268
- const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
269
- const canScrollToPosition = savedY <= maxScrollY;
270
-
271
- if (canScrollToPosition) {
272
- window.scrollTo(0, savedY);
273
- debugLog("[Scroll] Restored position:", savedY, "for key:", key);
274
- return true;
275
- }
276
-
277
- // Scroll as far as we can for now
278
- window.scrollTo(0, maxScrollY);
279
- debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
280
-
281
- // Poll while streaming until we can scroll to target position
281
+ // If streaming, poll until streaming ends then scroll to saved position
282
282
  if (options?.retryIfStreaming && options?.isStreaming?.()) {
283
283
  const startTime = Date.now();
284
284
 
285
285
  pendingPollInterval = setInterval(() => {
286
- // Stop if we've exceeded the timeout
287
286
  if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
288
287
  debugLog("[Scroll] Polling timeout, giving up");
289
288
  cancelScrollRestorationPolling();
290
289
  return;
291
290
  }
292
291
 
293
- // Stop if streaming ended
294
292
  if (!options.isStreaming?.()) {
295
- debugLog("[Scroll] Streaming ended, stopping poll");
296
- cancelScrollRestorationPolling();
297
- return;
298
- }
299
-
300
- // Check if we can now scroll to the target position
301
- const currentMaxScrollY =
302
- document.documentElement.scrollHeight - window.innerHeight;
303
- if (savedY <= currentMaxScrollY) {
304
293
  window.scrollTo(0, savedY);
305
- debugLog("[Scroll] Poll restored position:", savedY);
294
+ debugLog("[Scroll] Restored after streaming:", savedY);
306
295
  cancelScrollRestorationPolling();
307
296
  }
308
297
  }, SCROLL_POLL_INTERVAL_MS);
298
+
299
+ return true;
309
300
  }
310
301
 
311
- return false;
302
+ // Not streaming — scroll after React commits and browser paints.
303
+ // startTransition defers the DOM commit, so scrolling synchronously
304
+ // would be overwritten when React replaces the content.
305
+ deferToNextPaint(() => {
306
+ window.scrollTo(0, savedY);
307
+ debugLog("[Scroll] Restored position:", savedY, "for key:", key);
308
+ });
309
+ return true;
312
310
  }
313
311
 
314
312
  /**
@@ -339,6 +337,8 @@ export function scrollToHash(): boolean {
339
337
  * Scroll to top of page
340
338
  */
341
339
  export function scrollToTop(): void {
340
+ if (typeof window === "undefined") return;
341
+ if (typeof window.scrollTo !== "function") return;
342
342
  window.scrollTo(0, 0);
343
343
  }
344
344
 
@@ -363,31 +363,43 @@ export function handleNavigationEnd(options: {
363
363
  scroll?: boolean;
364
364
  isStreaming?: () => boolean;
365
365
  }): void {
366
- if (!initialized) {
367
- return;
368
- }
369
-
370
366
  const { restore = false, scroll = true, isStreaming } = options;
371
367
 
372
- // Don't scroll if explicitly disabled
373
- if (scroll === false) {
368
+ // Don't scroll if explicitly disabled or not in a browser
369
+ if (scroll === false || typeof window === "undefined") {
374
370
  return;
375
371
  }
376
372
 
377
- // For back/forward (restore), try to restore saved position
378
- if (restore) {
373
+ // Save/restore requires initialization (sessionStorage, history state).
374
+ // But basic scroll-to-top and hash scrolling work without it — this
375
+ // matters during cross-app navigation where ScrollRestoration unmounts
376
+ // and remounts, creating a brief window where initialized is false.
377
+ if (restore && initialized) {
379
378
  if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
380
379
  return;
381
380
  }
382
381
  // Fall through to hash or top if no saved position
383
382
  }
384
383
 
385
- // Try hash scrolling first
384
+ // scrollToHash / scrollToTop run synchronously here.
385
+ // handleNavigationEnd is invoked from NavigationProvider's
386
+ // useLayoutEffect (post-commit, pre-paint), so a sync scrollTo is
387
+ // captured by the upcoming paint AND by startViewTransition's snapshot.
388
+ // Deferring via rAF here pushed the call past the snapshot capture,
389
+ // making forward navigations wrapped in a layout/route view transition
390
+ // skip scroll-to-top — the live DOM scrolled but the captured snapshot
391
+ // was at the previous scroll position, so the user-facing page stayed
392
+ // visually clamped at the source page's scrollY (often the new tree's
393
+ // max scroll for tall→short navs). Y=0 / a hash element are robust
394
+ // against unmeasured layout, so sync scroll is correct here even
395
+ // before the new tree's scrollHeight settles.
396
+ //
397
+ // (The restore branch above keeps deferToNextPaint because savedY
398
+ // depends on the new tree's max scroll; sync scrollTo against an
399
+ // unmeasured DOM would clamp savedY to whatever the old/zero max was.)
386
400
  if (scrollToHash()) {
387
401
  return;
388
402
  }
389
-
390
- // Default: scroll to top
391
403
  scrollToTop();
392
404
  }
393
405