@rangojs/router 0.0.0-experimental.1b930379 → 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 (400) hide show
  1. package/AGENTS.md +12 -0
  2. package/README.md +245 -49
  3. package/dist/bin/rango.js +441 -134
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3453 -1240
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +76 -21
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +64 -2
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +247 -23
  12. package/skills/caching/SKILL.md +318 -15
  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 +250 -30
  19. package/skills/host-router/SKILL.md +83 -23
  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 +279 -53
  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 +153 -109
  32. package/skills/rango/SKILL.md +251 -22
  33. package/skills/react-compiler/SKILL.md +168 -0
  34. package/skills/response-routes/SKILL.md +123 -48
  35. package/skills/route/SKILL.md +101 -5
  36. package/skills/router-setup/SKILL.md +116 -8
  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 +129 -0
  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 +332 -29
  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 +1 -66
  62. package/src/browser/action-coordinator.ts +53 -36
  63. package/src/browser/action-fence.ts +47 -0
  64. package/src/browser/app-shell.ts +39 -0
  65. package/src/browser/app-version.ts +14 -0
  66. package/src/browser/connection-warmup.ts +134 -0
  67. package/src/browser/cookie-name.ts +140 -0
  68. package/src/browser/event-controller.ts +197 -150
  69. package/src/browser/history-state.ts +21 -0
  70. package/src/browser/index.ts +3 -3
  71. package/src/browser/invalidate-client-cache.ts +52 -0
  72. package/src/browser/navigation-bridge.ts +111 -31
  73. package/src/browser/navigation-client.ts +201 -67
  74. package/src/browser/navigation-store-handle.ts +38 -0
  75. package/src/browser/navigation-store.ts +76 -67
  76. package/src/browser/navigation-transaction.ts +18 -66
  77. package/src/browser/network-error-handler.ts +34 -7
  78. package/src/browser/partial-update.ts +187 -112
  79. package/src/browser/prefetch/cache.ts +230 -35
  80. package/src/browser/prefetch/fetch.ts +338 -39
  81. package/src/browser/prefetch/queue.ts +126 -20
  82. package/src/browser/prefetch/resource-ready.ts +77 -0
  83. package/src/browser/rango-state.ts +158 -76
  84. package/src/browser/react/Link.tsx +111 -16
  85. package/src/browser/react/NavigationProvider.tsx +135 -120
  86. package/src/browser/react/ScrollRestoration.tsx +10 -6
  87. package/src/browser/react/context.ts +7 -2
  88. package/src/browser/react/filter-segment-order.ts +66 -7
  89. package/src/browser/react/index.ts +0 -48
  90. package/src/browser/react/location-state-shared.ts +178 -8
  91. package/src/browser/react/location-state.ts +39 -14
  92. package/src/browser/react/use-action.ts +6 -15
  93. package/src/browser/react/use-handle.ts +23 -69
  94. package/src/browser/react/use-href.tsx +8 -1
  95. package/src/browser/react/use-link-status.ts +33 -8
  96. package/src/browser/react/use-navigation.ts +32 -7
  97. package/src/browser/react/use-params.ts +20 -10
  98. package/src/browser/react/use-reverse.ts +106 -0
  99. package/src/browser/react/use-router.ts +46 -11
  100. package/src/browser/react/use-search-params.ts +0 -5
  101. package/src/browser/react/use-segments.ts +11 -21
  102. package/src/browser/response-adapter.ts +80 -5
  103. package/src/browser/rsc-router.tsx +218 -76
  104. package/src/browser/scroll-restoration.ts +54 -42
  105. package/src/browser/segment-reconciler.ts +36 -9
  106. package/src/browser/segment-structure-assert.ts +2 -2
  107. package/src/browser/server-action-bridge.ts +222 -61
  108. package/src/browser/types.ts +91 -11
  109. package/src/browser/validate-redirect-origin.ts +43 -16
  110. package/src/build/collect-fallback-refs.ts +107 -0
  111. package/src/build/generate-manifest.ts +65 -40
  112. package/src/build/generate-route-types.ts +5 -1
  113. package/src/build/index.ts +8 -2
  114. package/src/build/prefix-tree-utils.ts +123 -0
  115. package/src/build/route-trie.ts +165 -36
  116. package/src/build/route-types/ast-route-extraction.ts +15 -8
  117. package/src/build/route-types/codegen.ts +16 -5
  118. package/src/build/route-types/include-resolution.ts +125 -24
  119. package/src/build/route-types/param-extraction.ts +6 -3
  120. package/src/build/route-types/per-module-writer.ts +22 -6
  121. package/src/build/route-types/router-processing.ts +272 -96
  122. package/src/build/route-types/scan-filter.ts +9 -2
  123. package/src/build/route-types/source-scan.ts +216 -0
  124. package/src/build/runtime-discovery.ts +9 -20
  125. package/src/cache/cache-error.ts +104 -0
  126. package/src/cache/cache-key-utils.ts +29 -13
  127. package/src/cache/cache-policy.ts +108 -34
  128. package/src/cache/cache-runtime.ts +214 -48
  129. package/src/cache/cache-scope.ts +236 -89
  130. package/src/cache/cache-tag.ts +103 -0
  131. package/src/cache/cf/cf-base64.ts +33 -0
  132. package/src/cache/cf/cf-cache-constants.ts +127 -0
  133. package/src/cache/cf/cf-cache-store.ts +2224 -171
  134. package/src/cache/cf/cf-cache-types.ts +349 -0
  135. package/src/cache/cf/cf-kv-utils.ts +46 -0
  136. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  137. package/src/cache/cf/index.ts +11 -17
  138. package/src/cache/document-cache.ts +89 -27
  139. package/src/cache/handle-snapshot.ts +70 -0
  140. package/src/cache/index.ts +11 -20
  141. package/src/cache/memory-segment-store.ts +136 -37
  142. package/src/cache/profile-registry.ts +31 -31
  143. package/src/cache/read-through-swr.ts +41 -11
  144. package/src/cache/segment-codec.ts +9 -17
  145. package/src/cache/tag-invalidation.ts +230 -0
  146. package/src/cache/taint.ts +55 -0
  147. package/src/cache/types.ts +37 -100
  148. package/src/client.rsc.tsx +44 -21
  149. package/src/client.tsx +119 -290
  150. package/src/cloudflare/index.ts +11 -0
  151. package/src/cloudflare/tracing.ts +109 -0
  152. package/src/component-utils.ts +19 -0
  153. package/src/components/DefaultDocument.tsx +8 -2
  154. package/src/context-var.ts +84 -2
  155. package/src/debug.ts +2 -2
  156. package/src/decode-loader-results.ts +52 -0
  157. package/src/defer.ts +196 -0
  158. package/src/deps/ssr.ts +0 -1
  159. package/src/encode-kv.ts +49 -0
  160. package/src/errors.ts +30 -4
  161. package/src/escape-script.ts +52 -0
  162. package/src/handle.ts +70 -22
  163. package/src/handles/MetaTags.tsx +56 -19
  164. package/src/handles/Scripts.tsx +183 -0
  165. package/src/handles/breadcrumbs.ts +37 -8
  166. package/src/handles/is-thenable.ts +19 -0
  167. package/src/handles/meta.ts +51 -40
  168. package/src/handles/script.ts +244 -0
  169. package/src/host/cookie-handler.ts +9 -60
  170. package/src/host/errors.ts +0 -24
  171. package/src/host/index.ts +8 -2
  172. package/src/host/pattern-matcher.ts +23 -52
  173. package/src/host/router.ts +107 -99
  174. package/src/host/testing.ts +40 -27
  175. package/src/host/types.ts +37 -4
  176. package/src/host/utils.ts +1 -1
  177. package/src/href-client.ts +137 -22
  178. package/src/index.rsc.ts +93 -12
  179. package/src/index.ts +133 -15
  180. package/src/internal-debug.ts +11 -10
  181. package/src/loader-store.ts +500 -0
  182. package/src/loader.rsc.ts +20 -13
  183. package/src/loader.ts +12 -11
  184. package/src/missing-id-error.ts +68 -0
  185. package/src/outlet-context.ts +1 -1
  186. package/src/outlet-provider.tsx +1 -5
  187. package/src/prerender/param-hash.ts +16 -16
  188. package/src/prerender/store.ts +37 -41
  189. package/src/prerender.ts +198 -82
  190. package/src/redirect-origin.ts +100 -0
  191. package/src/regex-escape.ts +8 -0
  192. package/src/render-error-thrower.tsx +20 -0
  193. package/src/response-utils.ts +62 -0
  194. package/src/reverse.ts +65 -15
  195. package/src/root-error-boundary.tsx +1 -19
  196. package/src/route-content-wrapper.tsx +7 -72
  197. package/src/route-definition/dsl-helpers.ts +469 -276
  198. package/src/route-definition/helper-factories.ts +29 -139
  199. package/src/route-definition/helpers-types.ts +113 -37
  200. package/src/route-definition/index.ts +3 -0
  201. package/src/route-definition/redirect.ts +53 -12
  202. package/src/route-definition/resolve-handler-use.ts +161 -0
  203. package/src/route-definition/use-item-types.ts +32 -0
  204. package/src/route-map-builder.ts +7 -17
  205. package/src/route-types.ts +37 -41
  206. package/src/router/basename.ts +14 -0
  207. package/src/router/content-negotiation.ts +164 -17
  208. package/src/router/error-handling.ts +45 -18
  209. package/src/router/find-match.ts +45 -22
  210. package/src/router/handler-context.ts +83 -39
  211. package/src/router/instrument.ts +350 -0
  212. package/src/router/intercept-resolution.ts +50 -24
  213. package/src/router/lazy-includes.ts +19 -53
  214. package/src/router/loader-resolution.ts +274 -56
  215. package/src/router/logging.ts +5 -8
  216. package/src/router/manifest.ts +49 -45
  217. package/src/router/match-api.ts +120 -204
  218. package/src/router/match-context.ts +0 -22
  219. package/src/router/match-handlers.ts +58 -58
  220. package/src/router/match-middleware/background-revalidation.ts +33 -6
  221. package/src/router/match-middleware/cache-lookup.ts +214 -263
  222. package/src/router/match-middleware/cache-store.ts +73 -33
  223. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  224. package/src/router/match-middleware/segment-resolution.ts +52 -18
  225. package/src/router/match-pipelines.ts +1 -42
  226. package/src/router/match-result.ts +104 -40
  227. package/src/router/metrics.ts +5 -34
  228. package/src/router/middleware-types.ts +13 -142
  229. package/src/router/middleware.ts +270 -172
  230. package/src/router/navigation-snapshot.ts +131 -0
  231. package/src/router/params-util.ts +23 -0
  232. package/src/router/pattern-matching.ts +132 -90
  233. package/src/router/prefetch-cache-ttl.ts +51 -0
  234. package/src/router/prerender-match.ts +195 -56
  235. package/src/router/preview-match.ts +32 -102
  236. package/src/router/request-classification.ts +276 -0
  237. package/src/router/revalidation.ts +123 -73
  238. package/src/router/route-snapshot.ts +244 -0
  239. package/src/router/router-context.ts +8 -28
  240. package/src/router/router-interfaces.ts +115 -35
  241. package/src/router/router-options.ts +172 -15
  242. package/src/router/router-registry.ts +2 -5
  243. package/src/router/segment-resolution/fresh.ts +264 -77
  244. package/src/router/segment-resolution/helpers.ts +115 -30
  245. package/src/router/segment-resolution/loader-cache.ts +63 -37
  246. package/src/router/segment-resolution/revalidation.ts +474 -385
  247. package/src/router/segment-resolution/static-store.ts +19 -5
  248. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  249. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  250. package/src/router/segment-resolution.ts +5 -1
  251. package/src/router/segment-wrappers.ts +8 -5
  252. package/src/router/state-cookie-name.ts +33 -0
  253. package/src/router/substitute-pattern-params.ts +56 -0
  254. package/src/router/telemetry-otel.ts +161 -199
  255. package/src/router/telemetry.ts +96 -19
  256. package/src/router/timeout.ts +0 -20
  257. package/src/router/tracing.ts +206 -0
  258. package/src/router/trie-matching.ts +163 -59
  259. package/src/router/types.ts +10 -63
  260. package/src/router/url-params.ts +44 -0
  261. package/src/router.ts +163 -55
  262. package/src/rsc/handler-context.ts +3 -2
  263. package/src/rsc/handler.ts +658 -511
  264. package/src/rsc/helpers.ts +168 -46
  265. package/src/rsc/index.ts +2 -5
  266. package/src/rsc/json-route-result.ts +38 -0
  267. package/src/rsc/loader-fetch.ts +127 -31
  268. package/src/rsc/manifest-init.ts +33 -42
  269. package/src/rsc/origin-guard.ts +39 -25
  270. package/src/rsc/progressive-enhancement.ts +77 -11
  271. package/src/rsc/redirect-guard.ts +99 -0
  272. package/src/rsc/response-cache-serve.ts +238 -0
  273. package/src/rsc/response-error.ts +79 -12
  274. package/src/rsc/response-route-handler.ts +99 -189
  275. package/src/rsc/rsc-rendering.ts +105 -72
  276. package/src/rsc/runtime-warnings.ts +23 -10
  277. package/src/rsc/server-action.ts +263 -112
  278. package/src/rsc/ssr-setup.ts +18 -2
  279. package/src/rsc/types.ts +32 -6
  280. package/src/runtime-env.ts +18 -0
  281. package/src/search-params.ts +35 -30
  282. package/src/segment-content-promise.ts +67 -0
  283. package/src/segment-loader-promise.ts +149 -0
  284. package/src/segment-system.tsx +281 -129
  285. package/src/serialize.ts +243 -0
  286. package/src/server/context.ts +309 -61
  287. package/src/server/cookie-parse.ts +32 -0
  288. package/src/server/cookie-store.ts +80 -5
  289. package/src/server/handle-store.ts +40 -38
  290. package/src/server/loader-registry.ts +26 -46
  291. package/src/server/request-context.ts +398 -172
  292. package/src/ssr/index.tsx +25 -16
  293. package/src/static-handler.ts +27 -18
  294. package/src/testing/cache-status.ts +162 -0
  295. package/src/testing/collect-handle.ts +40 -0
  296. package/src/testing/dispatch.ts +701 -0
  297. package/src/testing/dom.entry.ts +22 -0
  298. package/src/testing/e2e/fixture.ts +188 -0
  299. package/src/testing/e2e/index.ts +128 -0
  300. package/src/testing/e2e/matchers.ts +35 -0
  301. package/src/testing/e2e/page-helpers.ts +272 -0
  302. package/src/testing/e2e/parity.ts +387 -0
  303. package/src/testing/e2e/server.ts +195 -0
  304. package/src/testing/flight-matchers.ts +97 -0
  305. package/src/testing/flight-normalize.ts +11 -0
  306. package/src/testing/flight-runtime.d.ts +57 -0
  307. package/src/testing/flight-tree.ts +682 -0
  308. package/src/testing/flight.entry.ts +52 -0
  309. package/src/testing/flight.ts +257 -0
  310. package/src/testing/generated-routes.ts +183 -0
  311. package/src/testing/index.ts +99 -0
  312. package/src/testing/internal/context.ts +371 -0
  313. package/src/testing/internal/flight-client-globals.ts +30 -0
  314. package/src/testing/internal/seed-vars.ts +54 -0
  315. package/src/testing/render-handler.ts +343 -0
  316. package/src/testing/render-route.tsx +581 -0
  317. package/src/testing/run-loader.ts +385 -0
  318. package/src/testing/run-middleware.ts +205 -0
  319. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  320. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  321. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  322. package/src/testing/vitest-stubs/version.ts +5 -0
  323. package/src/testing/vitest.ts +305 -0
  324. package/src/theme/ThemeProvider.tsx +20 -58
  325. package/src/theme/ThemeScript.tsx +7 -9
  326. package/src/theme/constants.ts +52 -13
  327. package/src/theme/index.ts +0 -7
  328. package/src/theme/theme-context.ts +1 -5
  329. package/src/theme/theme-script.ts +22 -21
  330. package/src/theme/use-theme.ts +0 -3
  331. package/src/types/boundaries.ts +0 -35
  332. package/src/types/cache-types.ts +17 -8
  333. package/src/types/error-types.ts +30 -90
  334. package/src/types/global-namespace.ts +54 -41
  335. package/src/types/handler-context.ts +233 -81
  336. package/src/types/index.ts +1 -10
  337. package/src/types/loader-types.ts +44 -15
  338. package/src/types/request-scope.ts +112 -0
  339. package/src/types/route-config.ts +6 -50
  340. package/src/types/route-entry.ts +19 -7
  341. package/src/types/segments.ts +37 -14
  342. package/src/urls/include-helper.ts +33 -70
  343. package/src/urls/index.ts +1 -11
  344. package/src/urls/path-helper-types.ts +58 -11
  345. package/src/urls/path-helper.ts +57 -111
  346. package/src/urls/pattern-types.ts +48 -19
  347. package/src/urls/response-types.ts +25 -22
  348. package/src/urls/type-extraction.ts +58 -139
  349. package/src/urls/urls-function.ts +1 -18
  350. package/src/use-loader.tsx +346 -89
  351. package/src/vite/debug.ts +185 -0
  352. package/src/vite/discovery/bundle-postprocess.ts +36 -38
  353. package/src/vite/discovery/discover-routers.ts +130 -85
  354. package/src/vite/discovery/discovery-errors.ts +194 -0
  355. package/src/vite/discovery/gate-state.ts +171 -0
  356. package/src/vite/discovery/prerender-collection.ts +214 -132
  357. package/src/vite/discovery/route-types-writer.ts +40 -84
  358. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  359. package/src/vite/discovery/state.ts +57 -6
  360. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  361. package/src/vite/index.ts +6 -0
  362. package/src/vite/inject-client-debug.ts +36 -0
  363. package/src/vite/plugin-types.ts +155 -65
  364. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  365. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  366. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  367. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  368. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  369. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  370. package/src/vite/plugins/expose-action-id.ts +49 -98
  371. package/src/vite/plugins/expose-id-utils.ts +96 -51
  372. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  373. package/src/vite/plugins/expose-ids/handler-transform.ts +15 -64
  374. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  375. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  376. package/src/vite/plugins/expose-internal-ids.ts +553 -317
  377. package/src/vite/plugins/performance-tracks.ts +89 -0
  378. package/src/vite/plugins/refresh-cmd.ts +89 -27
  379. package/src/vite/plugins/use-cache-transform.ts +73 -83
  380. package/src/vite/plugins/version-injector.ts +21 -25
  381. package/src/vite/plugins/version-plugin.ts +46 -37
  382. package/src/vite/plugins/virtual-entries.ts +13 -18
  383. package/src/vite/rango.ts +238 -295
  384. package/src/vite/router-discovery.ts +940 -149
  385. package/src/vite/utils/ast-handler-extract.ts +26 -35
  386. package/src/vite/utils/banner.ts +4 -4
  387. package/src/vite/utils/bundle-analysis.ts +10 -15
  388. package/src/vite/utils/client-chunks.ts +184 -0
  389. package/src/vite/utils/directive-prologue.ts +40 -0
  390. package/src/vite/utils/forward-user-plugins.ts +171 -0
  391. package/src/vite/utils/manifest-utils.ts +4 -59
  392. package/src/vite/utils/package-resolution.ts +20 -52
  393. package/src/vite/utils/prerender-utils.ts +81 -34
  394. package/src/vite/utils/shared-utils.ts +92 -42
  395. package/src/browser/action-response-classifier.ts +0 -99
  396. package/src/browser/react/use-client-cache.ts +0 -58
  397. package/src/browser/shallow.ts +0 -40
  398. package/src/handles/index.ts +0 -7
  399. package/src/network-error-thrower.tsx +0 -23
  400. 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,11 +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";
25
27
  import { initPrefetchCache } from "./prefetch/cache.js";
28
+ import { setPrefetchDecoder } from "./prefetch/fetch.js";
29
+ import { setAppVersion } from "./app-version.js";
26
30
  import {
27
31
  isInterceptSegment,
28
32
  splitInterceptSegments,
29
33
  } from "./intercept-utils.js";
34
+ import { createAppShellRef } from "./app-shell.js";
30
35
 
31
36
  // Vite HMR types are provided by vite/client
32
37
 
@@ -111,15 +116,26 @@ export interface BrowserAppContext {
111
116
  initialTheme?: Theme;
112
117
  /** Whether connection warmup is enabled */
113
118
  warmupEnabled?: boolean;
119
+ /** Whether the hydrated tree should be wrapped in React.StrictMode */
120
+ strictMode?: boolean;
114
121
  /** App version for prefetch version mismatch detection */
115
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;
116
132
  }
117
133
 
118
134
  // Module-level state for the initialized app
119
135
  let browserAppContext: BrowserAppContext | null = null;
120
136
 
121
137
  /**
122
- * Initialize the browser app. Must be called before rendering RSCRouter.
138
+ * Initialize the browser app. Must be called before rendering Rango.
123
139
  *
124
140
  * This function:
125
141
  * - Loads the initial RSC payload from the stream
@@ -139,7 +155,6 @@ export async function initBrowserApp(
139
155
  initialTheme,
140
156
  } = options;
141
157
 
142
- // Load initial payload from SSR-injected __FLIGHT_DATA__
143
158
  const initialPayload =
144
159
  await deps.createFromReadableStream<RscPayload>(rscStream);
145
160
 
@@ -164,6 +179,18 @@ export async function initBrowserApp(
164
179
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
165
180
  });
166
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
+
167
194
  // Create event controller for reactive state management
168
195
  const eventController = createEventController({
169
196
  initialLocation: new URL(window.location.href),
@@ -198,13 +225,25 @@ export async function initBrowserApp(
198
225
  // Create composable utilities
199
226
  const client = createNavigationClient(deps);
200
227
 
201
- // Extract rootLayout and version from metadata for browser-side re-renders
202
- 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.
203
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
+ });
204
240
 
205
- // Initialize the localStorage state key for cache invalidation.
206
- // Uses the build version so a new deploy automatically busts all cached prefetches.
207
- 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);
208
247
 
209
248
  // Initialize the in-memory prefetch cache TTL from server config.
210
249
  // A value of 0 disables the cache; undefined falls back to the module default.
@@ -213,11 +252,22 @@ export async function initBrowserApp(
213
252
  initPrefetchCache(prefetchCacheTTL);
214
253
  }
215
254
 
216
- // 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.
217
263
  const renderSegments = (
218
264
  segments: ResolvedSegment[],
219
265
  options?: RenderSegmentsOptions,
220
- ) => baseRenderSegments(segments, { ...options, rootLayout });
266
+ ) =>
267
+ baseRenderSegments(segments, {
268
+ ...options,
269
+ rootLayout: appShellRef.get().rootLayout,
270
+ });
221
271
 
222
272
  // Lazy reference for navigation bridge — the action bridge is created first
223
273
  // but may need to trigger SPA navigation for action redirects.
@@ -231,10 +281,15 @@ export async function initBrowserApp(
231
281
  deps,
232
282
  onUpdate: (update) => store.emitUpdate(update),
233
283
  renderSegments,
234
- version,
235
284
  onNavigate: (url, options) => {
236
285
  if (!navigateFn) {
237
- 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
+ }
238
293
  return Promise.resolve();
239
294
  }
240
295
  return navigateFn(url, options);
@@ -249,7 +304,7 @@ export async function initBrowserApp(
249
304
  client,
250
305
  onUpdate: (update) => store.emitUpdate(update),
251
306
  renderSegments,
252
- version,
307
+ version: version,
253
308
  });
254
309
 
255
310
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -263,75 +318,157 @@ export async function initBrowserApp(
263
318
  // Build initial tree with rootLayout
264
319
  const initialTree = renderSegments(initialPayload.metadata!.segments);
265
320
 
266
- // 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).
267
325
  if (import.meta.hot) {
268
- import.meta.hot.on("rsc:update", async () => {
269
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
270
-
271
- const handle = eventController.startNavigation(window.location.href, {
272
- replace: true,
273
- });
274
- const streamingToken = handle.startStreaming();
275
-
276
- const interceptSourceUrl = store.getInterceptSourceUrl();
277
-
278
- try {
279
- const { payload, streamComplete } = await client.fetchPartial({
280
- targetUrl: window.location.href,
281
- segmentIds: [],
282
- previousUrl: store.getSegmentState().currentUrl,
283
- interceptSourceUrl: interceptSourceUrl || undefined,
284
- 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,
285
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
+ });
286
378
 
287
- if (payload.metadata?.isPartial) {
288
- const segments = payload.metadata.segments || [];
289
- const matched = payload.metadata.matched || [];
379
+ if (abort.signal.aborted) return;
290
380
 
291
- // Derive intercept state from the returned payload, not the
292
- // pre-fetch store snapshot. If the HMR edit removed intercept
293
- // behavior, the response won't contain intercept segments.
294
- 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
+ }
295
387
 
296
- // Sync store intercept state with what the server returned
297
- if (!responseIsIntercept && interceptSourceUrl) {
298
- 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);
299
408
  }
300
409
 
301
- store.setSegmentIds(matched);
302
- 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
+ }
303
453
 
304
- const historyKey = generateHistoryKey(window.location.href, {
305
- intercept: responseIsIntercept,
306
- });
307
- store.setHistoryKey(historyKey);
308
- const currentHandleData = eventController.getHandleState().data;
309
- store.cacheSegmentsForHistory(
310
- historyKey,
311
- segments,
312
- currentHandleData,
313
- );
314
-
315
- const { main, intercept } = splitInterceptSegments(segments);
316
- store.emitUpdate({
317
- root: renderSegments(main, {
318
- interceptSegments: intercept.length > 0 ? intercept : undefined,
319
- }),
320
- metadata: payload.metadata,
321
- });
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]();
322
466
  }
323
-
324
- await streamComplete;
325
- handle.complete(new URL(window.location.href));
326
- console.log("[RSCRouter] HMR: RSC stream complete");
327
- } finally {
328
- streamingToken.end();
329
- handle[Symbol.dispose]();
330
- }
467
+ }, 200);
331
468
  });
332
469
  }
333
470
 
334
- // Store context for RSCRouter component
471
+ // Store context for Rango component
335
472
  const context: BrowserAppContext = {
336
473
  store,
337
474
  eventController,
@@ -341,7 +478,9 @@ export async function initBrowserApp(
341
478
  themeConfig: effectiveThemeConfig,
342
479
  initialTheme: effectiveInitialTheme,
343
480
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
481
+ strictMode: initialPayload.metadata?.strictMode ?? true,
344
482
  version,
483
+ appShellRef,
345
484
  };
346
485
  browserAppContext = context;
347
486
 
@@ -354,7 +493,7 @@ export async function initBrowserApp(
354
493
  export function getBrowserAppContext(): BrowserAppContext {
355
494
  if (!browserAppContext) {
356
495
  throw new Error(
357
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
496
+ "Rango: initBrowserApp() must be called before rendering Rango",
358
497
  );
359
498
  }
360
499
  return browserAppContext;
@@ -368,18 +507,18 @@ export function resetBrowserAppContext(): void {
368
507
  }
369
508
 
370
509
  /**
371
- * Props for the RSCRouter component
510
+ * Props for the Rango component
372
511
  */
373
- export interface RSCRouterProps {}
512
+ export interface RangoProps {}
374
513
 
375
514
  /**
376
- * RSCRouter component - renders the RSC router with all internal wiring.
515
+ * Rango component - renders the RSC router with all internal wiring.
377
516
  *
378
517
  * Must be called after initBrowserApp() has completed.
379
518
  *
380
519
  * @example
381
520
  * ```tsx
382
- * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
521
+ * import { initBrowserApp, Rango } from "rsc-router/browser";
383
522
  * import { rscStream } from "rsc-html-stream/client";
384
523
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
385
524
  *
@@ -389,14 +528,14 @@ export interface RSCRouterProps {}
389
528
  * hydrateRoot(
390
529
  * document,
391
530
  * <React.StrictMode>
392
- * <RSCRouter />
531
+ * <Rango />
393
532
  * </React.StrictMode>
394
533
  * );
395
534
  * }
396
535
  * main();
397
536
  * ```
398
537
  */
399
- export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
538
+ export function Rango(_props: RangoProps): React.ReactElement {
400
539
  const {
401
540
  store,
402
541
  eventController,
@@ -407,6 +546,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
407
546
  initialTheme,
408
547
  warmupEnabled,
409
548
  version,
549
+ appShellRef,
410
550
  } = getBrowserAppContext();
411
551
 
412
552
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -426,6 +566,8 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
426
566
  initialTheme={initialTheme}
427
567
  warmupEnabled={warmupEnabled}
428
568
  version={version}
569
+ basename={initialPayload.metadata?.basename}
570
+ appShellRef={appShellRef}
429
571
  />
430
572
  );
431
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