@rangojs/router 0.0.0-experimental.a769fbe7 → 0.0.0-experimental.ac99d918

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 (374) hide show
  1. package/README.md +211 -43
  2. package/dist/bin/rango.js +279 -102
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +3313 -1160
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +62 -11
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +63 -1
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +222 -30
  12. package/skills/caching/SKILL.md +263 -8
  13. package/skills/composability/SKILL.md +27 -2
  14. package/skills/css/SKILL.md +76 -0
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +364 -0
  17. package/skills/hooks/SKILL.md +250 -30
  18. package/skills/host-router/SKILL.md +124 -22
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +49 -5
  21. package/skills/layout/SKILL.md +35 -9
  22. package/skills/links/SKILL.md +249 -17
  23. package/skills/loader/SKILL.md +223 -9
  24. package/skills/middleware/SKILL.md +52 -13
  25. package/skills/migrate-nextjs/SKILL.md +584 -0
  26. package/skills/migrate-react-router/SKILL.md +769 -0
  27. package/skills/mime-routes/SKILL.md +27 -0
  28. package/skills/observability/SKILL.md +137 -0
  29. package/skills/parallel/SKILL.md +77 -7
  30. package/skills/prerender/SKILL.md +123 -100
  31. package/skills/rango/SKILL.md +250 -22
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +122 -47
  34. package/skills/route/SKILL.md +66 -5
  35. package/skills/router-setup/SKILL.md +38 -3
  36. package/skills/server-actions/SKILL.md +775 -0
  37. package/skills/streams-and-websockets/SKILL.md +283 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +129 -0
  40. package/skills/testing/bindings.md +89 -0
  41. package/skills/testing/cache-prerender.md +124 -0
  42. package/skills/testing/client-components.md +122 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +92 -0
  45. package/skills/testing/handles.md +129 -0
  46. package/skills/testing/loader.md +128 -0
  47. package/skills/testing/middleware.md +99 -0
  48. package/skills/testing/render-handler.md +121 -0
  49. package/skills/testing/response-routes.md +95 -0
  50. package/skills/testing/reverse-and-types.md +84 -0
  51. package/skills/testing/server-actions.md +107 -0
  52. package/skills/testing/server-tree.md +128 -0
  53. package/skills/testing/setup.md +120 -0
  54. package/skills/typesafety/SKILL.md +319 -27
  55. package/skills/use-cache/SKILL.md +36 -5
  56. package/skills/vercel/SKILL.md +107 -0
  57. package/skills/view-transitions/SKILL.md +294 -0
  58. package/src/__augment-tests__/augment.ts +81 -0
  59. package/src/__augment-tests__/augmented.check.ts +116 -0
  60. package/src/__internal.ts +1 -66
  61. package/src/browser/action-coordinator.ts +53 -36
  62. package/src/browser/action-fence.ts +47 -0
  63. package/src/browser/app-shell.ts +39 -0
  64. package/src/browser/app-version.ts +14 -0
  65. package/src/browser/cookie-name.ts +140 -0
  66. package/src/browser/event-controller.ts +81 -147
  67. package/src/browser/history-state.ts +21 -0
  68. package/src/browser/index.ts +3 -3
  69. package/src/browser/invalidate-client-cache.ts +52 -0
  70. package/src/browser/navigation-bridge.ts +66 -14
  71. package/src/browser/navigation-client.ts +172 -109
  72. package/src/browser/navigation-store-handle.ts +38 -0
  73. package/src/browser/navigation-store.ts +76 -67
  74. package/src/browser/navigation-transaction.ts +9 -59
  75. package/src/browser/partial-update.ts +79 -93
  76. package/src/browser/prefetch/cache.ts +180 -62
  77. package/src/browser/prefetch/fetch.ts +252 -39
  78. package/src/browser/prefetch/queue.ts +42 -8
  79. package/src/browser/rango-state.ts +158 -76
  80. package/src/browser/react/Link.tsx +72 -10
  81. package/src/browser/react/NavigationProvider.tsx +83 -31
  82. package/src/browser/react/ScrollRestoration.tsx +10 -6
  83. package/src/browser/react/context.ts +7 -2
  84. package/src/browser/react/filter-segment-order.ts +49 -7
  85. package/src/browser/react/index.ts +0 -48
  86. package/src/browser/react/location-state-shared.ts +166 -8
  87. package/src/browser/react/location-state.ts +39 -14
  88. package/src/browser/react/use-action.ts +6 -15
  89. package/src/browser/react/use-handle.ts +23 -69
  90. package/src/browser/react/use-link-status.ts +0 -4
  91. package/src/browser/react/use-navigation.ts +22 -5
  92. package/src/browser/react/use-params.ts +20 -10
  93. package/src/browser/react/use-reverse.ts +106 -0
  94. package/src/browser/react/use-router.ts +46 -11
  95. package/src/browser/react/use-search-params.ts +0 -5
  96. package/src/browser/react/use-segments.ts +11 -21
  97. package/src/browser/response-adapter.ts +52 -1
  98. package/src/browser/rsc-router.tsx +111 -24
  99. package/src/browser/scroll-restoration.ts +29 -19
  100. package/src/browser/segment-reconciler.ts +36 -14
  101. package/src/browser/segment-structure-assert.ts +2 -2
  102. package/src/browser/server-action-bridge.ts +176 -62
  103. package/src/browser/types.ts +60 -11
  104. package/src/browser/validate-redirect-origin.ts +43 -16
  105. package/src/build/collect-fallback-refs.ts +107 -0
  106. package/src/build/generate-manifest.ts +65 -40
  107. package/src/build/generate-route-types.ts +6 -0
  108. package/src/build/index.ts +8 -2
  109. package/src/build/prefix-tree-utils.ts +123 -0
  110. package/src/build/route-trie.ts +137 -32
  111. package/src/build/route-types/codegen.ts +4 -4
  112. package/src/build/route-types/include-resolution.ts +9 -2
  113. package/src/build/route-types/param-extraction.ts +6 -3
  114. package/src/build/route-types/per-module-writer.ts +7 -4
  115. package/src/build/route-types/router-processing.ts +333 -94
  116. package/src/build/route-types/scan-filter.ts +9 -2
  117. package/src/build/route-types/source-scan.ts +118 -0
  118. package/src/build/runtime-discovery.ts +9 -20
  119. package/src/cache/cache-error.ts +104 -0
  120. package/src/cache/cache-policy.ts +68 -28
  121. package/src/cache/cache-runtime.ts +134 -32
  122. package/src/cache/cache-scope.ts +100 -74
  123. package/src/cache/cache-tag.ts +98 -0
  124. package/src/cache/cf/cf-cache-store.ts +2256 -241
  125. package/src/cache/cf/index.ts +6 -16
  126. package/src/cache/document-cache.ts +61 -20
  127. package/src/cache/handle-snapshot.ts +63 -0
  128. package/src/cache/index.ts +22 -20
  129. package/src/cache/memory-segment-store.ts +136 -37
  130. package/src/cache/profile-registry.ts +6 -30
  131. package/src/cache/read-through-swr.ts +41 -11
  132. package/src/cache/segment-codec.ts +0 -16
  133. package/src/cache/tag-invalidation.ts +230 -0
  134. package/src/cache/types.ts +33 -100
  135. package/src/cache/vercel/index.ts +11 -0
  136. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  137. package/src/client.rsc.tsx +6 -21
  138. package/src/client.tsx +108 -290
  139. package/src/component-utils.ts +19 -0
  140. package/src/context-var.ts +17 -5
  141. package/src/decode-loader-results.ts +36 -0
  142. package/src/defer.ts +196 -0
  143. package/src/deps/browser.ts +0 -1
  144. package/src/deps/ssr.ts +0 -1
  145. package/src/errors.ts +30 -4
  146. package/src/handle.ts +70 -22
  147. package/src/handles/MetaTags.tsx +0 -14
  148. package/src/handles/breadcrumbs.ts +16 -5
  149. package/src/handles/meta.ts +0 -39
  150. package/src/host/cookie-handler.ts +0 -36
  151. package/src/host/errors.ts +0 -24
  152. package/src/host/index.ts +8 -2
  153. package/src/host/pattern-matcher.ts +7 -50
  154. package/src/host/router.ts +107 -99
  155. package/src/host/testing.ts +40 -27
  156. package/src/host/types.ts +37 -4
  157. package/src/host/utils.ts +1 -1
  158. package/src/href-client.ts +137 -22
  159. package/src/index.rsc.ts +69 -10
  160. package/src/index.ts +112 -14
  161. package/src/internal-debug.ts +2 -4
  162. package/src/loader-store.ts +500 -0
  163. package/src/loader.rsc.ts +20 -13
  164. package/src/loader.ts +12 -11
  165. package/src/missing-id-error.ts +68 -0
  166. package/src/network-error-thrower.tsx +1 -6
  167. package/src/outlet-context.ts +1 -1
  168. package/src/outlet-provider.tsx +1 -5
  169. package/src/prerender/param-hash.ts +10 -11
  170. package/src/prerender/store.ts +37 -41
  171. package/src/prerender.ts +198 -82
  172. package/src/redirect-origin.ts +100 -0
  173. package/src/response-utils.ts +37 -0
  174. package/src/reverse.ts +65 -15
  175. package/src/root-error-boundary.tsx +1 -19
  176. package/src/route-content-wrapper.tsx +7 -72
  177. package/src/route-definition/dsl-helpers.ts +413 -275
  178. package/src/route-definition/helper-factories.ts +29 -139
  179. package/src/route-definition/helpers-types.ts +107 -32
  180. package/src/route-definition/index.ts +3 -0
  181. package/src/route-definition/redirect.ts +50 -8
  182. package/src/route-definition/resolve-handler-use.ts +161 -0
  183. package/src/route-definition/use-item-types.ts +32 -0
  184. package/src/route-map-builder.ts +0 -16
  185. package/src/route-types.ts +37 -41
  186. package/src/router/basename.ts +14 -0
  187. package/src/router/content-negotiation.ts +108 -9
  188. package/src/router/error-handling.ts +13 -17
  189. package/src/router/find-match.ts +44 -23
  190. package/src/router/handler-context.ts +46 -30
  191. package/src/router/intercept-resolution.ts +23 -23
  192. package/src/router/lazy-includes.ts +15 -52
  193. package/src/router/loader-resolution.ts +207 -30
  194. package/src/router/logging.ts +0 -6
  195. package/src/router/manifest.ts +40 -42
  196. package/src/router/match-api.ts +120 -204
  197. package/src/router/match-context.ts +0 -22
  198. package/src/router/match-handlers.ts +58 -58
  199. package/src/router/match-middleware/background-revalidation.ts +0 -7
  200. package/src/router/match-middleware/cache-lookup.ts +161 -262
  201. package/src/router/match-middleware/cache-store.ts +3 -33
  202. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  203. package/src/router/match-middleware/segment-resolution.ts +45 -14
  204. package/src/router/match-pipelines.ts +1 -42
  205. package/src/router/match-result.ts +87 -39
  206. package/src/router/metrics.ts +0 -34
  207. package/src/router/middleware-types.ts +7 -140
  208. package/src/router/middleware.ts +169 -140
  209. package/src/router/navigation-snapshot.ts +131 -0
  210. package/src/router/params-util.ts +23 -0
  211. package/src/router/pattern-matching.ts +109 -63
  212. package/src/router/prerender-match.ts +190 -54
  213. package/src/router/preview-match.ts +32 -102
  214. package/src/router/request-classification.ts +276 -0
  215. package/src/router/revalidation.ts +63 -55
  216. package/src/router/route-snapshot.ts +244 -0
  217. package/src/router/router-context.ts +0 -27
  218. package/src/router/router-interfaces.ts +100 -35
  219. package/src/router/router-options.ts +91 -11
  220. package/src/router/router-registry.ts +2 -5
  221. package/src/router/segment-resolution/fresh.ts +119 -65
  222. package/src/router/segment-resolution/helpers.ts +34 -0
  223. package/src/router/segment-resolution/loader-cache.ts +40 -37
  224. package/src/router/segment-resolution/revalidation.ts +329 -305
  225. package/src/router/segment-resolution/static-store.ts +19 -5
  226. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  227. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  228. package/src/router/segment-resolution.ts +4 -1
  229. package/src/router/segment-wrappers.ts +0 -3
  230. package/src/router/state-cookie-name.ts +33 -0
  231. package/src/router/substitute-pattern-params.ts +56 -0
  232. package/src/router/telemetry-otel.ts +0 -20
  233. package/src/router/telemetry.ts +96 -19
  234. package/src/router/timeout.ts +0 -20
  235. package/src/router/trie-matching.ts +91 -46
  236. package/src/router/types.ts +9 -63
  237. package/src/router/url-params.ts +44 -0
  238. package/src/router.ts +128 -42
  239. package/src/rsc/handler-context.ts +3 -2
  240. package/src/rsc/handler.ts +492 -409
  241. package/src/rsc/helpers.ts +162 -46
  242. package/src/rsc/index.ts +1 -1
  243. package/src/rsc/json-route-result.ts +38 -0
  244. package/src/rsc/loader-fetch.ts +18 -3
  245. package/src/rsc/manifest-init.ts +33 -42
  246. package/src/rsc/origin-guard.ts +39 -25
  247. package/src/rsc/progressive-enhancement.ts +28 -4
  248. package/src/rsc/redirect-guard.ts +99 -0
  249. package/src/rsc/response-error.ts +79 -12
  250. package/src/rsc/response-route-handler.ts +90 -63
  251. package/src/rsc/rsc-rendering.ts +53 -56
  252. package/src/rsc/runtime-warnings.ts +23 -10
  253. package/src/rsc/server-action.ts +74 -69
  254. package/src/rsc/ssr-setup.ts +18 -2
  255. package/src/rsc/types.ts +22 -9
  256. package/src/runtime-env.ts +18 -0
  257. package/src/search-params.ts +4 -20
  258. package/src/segment-content-promise.ts +67 -0
  259. package/src/segment-loader-promise.ts +134 -0
  260. package/src/segment-system.tsx +208 -201
  261. package/src/serialize.ts +243 -0
  262. package/src/server/context.ts +211 -52
  263. package/src/server/cookie-store.ts +80 -5
  264. package/src/server/handle-store.ts +26 -24
  265. package/src/server/loader-registry.ts +10 -28
  266. package/src/server/request-context.ts +289 -124
  267. package/src/ssr/index.tsx +22 -15
  268. package/src/static-handler.ts +27 -18
  269. package/src/testing/cache-status.ts +162 -0
  270. package/src/testing/collect-handle.ts +40 -0
  271. package/src/testing/dispatch.ts +618 -0
  272. package/src/testing/dom.entry.ts +22 -0
  273. package/src/testing/e2e/fixture.ts +188 -0
  274. package/src/testing/e2e/index.ts +128 -0
  275. package/src/testing/e2e/matchers.ts +35 -0
  276. package/src/testing/e2e/page-helpers.ts +272 -0
  277. package/src/testing/e2e/parity.ts +387 -0
  278. package/src/testing/e2e/server.ts +195 -0
  279. package/src/testing/flight-matchers.ts +97 -0
  280. package/src/testing/flight-normalize.ts +11 -0
  281. package/src/testing/flight-runtime.d.ts +57 -0
  282. package/src/testing/flight-tree.ts +682 -0
  283. package/src/testing/flight.entry.ts +52 -0
  284. package/src/testing/flight.ts +232 -0
  285. package/src/testing/generated-routes.ts +183 -0
  286. package/src/testing/index.ts +99 -0
  287. package/src/testing/internal/context.ts +348 -0
  288. package/src/testing/internal/flight-client-globals.ts +30 -0
  289. package/src/testing/internal/seed-vars.ts +54 -0
  290. package/src/testing/render-handler.ts +330 -0
  291. package/src/testing/render-route.tsx +566 -0
  292. package/src/testing/run-loader.ts +378 -0
  293. package/src/testing/run-middleware.ts +205 -0
  294. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  295. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  297. package/src/testing/vitest-stubs/version.ts +5 -0
  298. package/src/testing/vitest.ts +305 -0
  299. package/src/theme/ThemeProvider.tsx +0 -52
  300. package/src/theme/ThemeScript.tsx +0 -6
  301. package/src/theme/constants.ts +0 -12
  302. package/src/theme/index.ts +0 -7
  303. package/src/theme/theme-context.ts +1 -5
  304. package/src/theme/theme-script.ts +0 -14
  305. package/src/theme/use-theme.ts +0 -3
  306. package/src/types/boundaries.ts +0 -35
  307. package/src/types/cache-types.ts +17 -8
  308. package/src/types/error-types.ts +30 -90
  309. package/src/types/global-namespace.ts +54 -41
  310. package/src/types/handler-context.ts +124 -70
  311. package/src/types/index.ts +1 -10
  312. package/src/types/loader-types.ts +40 -11
  313. package/src/types/request-scope.ts +107 -0
  314. package/src/types/route-config.ts +6 -50
  315. package/src/types/route-entry.ts +12 -7
  316. package/src/types/segments.ts +36 -15
  317. package/src/urls/include-helper.ts +33 -70
  318. package/src/urls/index.ts +1 -11
  319. package/src/urls/path-helper-types.ts +58 -11
  320. package/src/urls/path-helper.ts +57 -111
  321. package/src/urls/pattern-types.ts +48 -19
  322. package/src/urls/response-types.ts +25 -22
  323. package/src/urls/type-extraction.ts +58 -139
  324. package/src/urls/urls-function.ts +1 -18
  325. package/src/use-loader.tsx +346 -89
  326. package/src/vite/debug.ts +185 -0
  327. package/src/vite/discovery/bundle-postprocess.ts +36 -38
  328. package/src/vite/discovery/discover-routers.ts +130 -85
  329. package/src/vite/discovery/discovery-errors.ts +194 -0
  330. package/src/vite/discovery/gate-state.ts +171 -0
  331. package/src/vite/discovery/prerender-collection.ts +192 -99
  332. package/src/vite/discovery/route-types-writer.ts +40 -84
  333. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  334. package/src/vite/discovery/state.ts +51 -4
  335. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  336. package/src/vite/index.ts +8 -0
  337. package/src/vite/plugin-types.ts +236 -6
  338. package/src/vite/plugins/cjs-to-esm.ts +8 -18
  339. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  340. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  341. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  342. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  343. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  344. package/src/vite/plugins/expose-action-id.ts +48 -95
  345. package/src/vite/plugins/expose-id-utils.ts +11 -50
  346. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  347. package/src/vite/plugins/expose-ids/handler-transform.ts +10 -48
  348. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  349. package/src/vite/plugins/expose-ids/router-transform.ts +20 -16
  350. package/src/vite/plugins/expose-internal-ids.ts +554 -317
  351. package/src/vite/plugins/performance-tracks.ts +65 -207
  352. package/src/vite/plugins/refresh-cmd.ts +89 -27
  353. package/src/vite/plugins/use-cache-transform.ts +73 -83
  354. package/src/vite/plugins/vercel-output.ts +258 -0
  355. package/src/vite/plugins/version-injector.ts +21 -25
  356. package/src/vite/plugins/version-plugin.ts +32 -23
  357. package/src/vite/plugins/virtual-entries.ts +46 -17
  358. package/src/vite/rango.ts +207 -125
  359. package/src/vite/router-discovery.ts +931 -133
  360. package/src/vite/utils/ast-handler-extract.ts +15 -31
  361. package/src/vite/utils/banner.ts +1 -1
  362. package/src/vite/utils/bundle-analysis.ts +10 -15
  363. package/src/vite/utils/client-chunks.ts +184 -0
  364. package/src/vite/utils/forward-user-plugins.ts +171 -0
  365. package/src/vite/utils/manifest-utils.ts +4 -59
  366. package/src/vite/utils/package-resolution.ts +20 -52
  367. package/src/vite/utils/prerender-utils.ts +27 -29
  368. package/src/vite/utils/shared-utils.ts +97 -44
  369. package/src/browser/action-response-classifier.ts +0 -99
  370. package/src/browser/debug-channel.ts +0 -93
  371. package/src/browser/react/use-client-cache.ts +0 -58
  372. package/src/browser/shallow.ts +0 -40
  373. package/src/handles/index.ts +0 -7
  374. package/src/router/middleware-cookies.ts +0 -55
@@ -1,112 +1,194 @@
1
1
  /**
2
2
  * Rango State
3
3
  *
4
- * Manages a localStorage-based state key for HTTP cache invalidation.
5
- * The key is sent as the `X-Rango-State` header on both prefetch and
6
- * navigation requests. The server responds with `Vary: X-Rango-State`,
7
- * so the browser HTTP cache keys responses by (URL, X-Rango-State value).
4
+ * Manages a session-cookie-based state value for HTTP cache invalidation. The
5
+ * value is sent as the `X-Rango-State` header on prefetch and navigation
6
+ * requests; the server responds with `Vary: X-Rango-State`, so the browser HTTP
7
+ * cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
- * Format: `{buildVersion}:{invalidationTimestamp}`
10
- * - Build version changes on deploy, busting all cached prefetches.
11
- * - Timestamp changes on server action invalidation.
9
+ * Value format: `{buildVersion}:{invalidationTimestamp}`
10
+ * - Build version changes on deploy, busting all cached prefetches at boot.
11
+ * - Timestamp rotates on invalidation (server action, invalidateClientCache).
12
12
  *
13
- * localStorage is cross-tab and survives page refresh, so:
14
- * - One tab's prefetch warms the cache for all tabs.
15
- * - Invalidation in one tab is picked up by other tabs on next fetch.
13
+ * Storage is a session cookie named by the server-resolved name passed to
14
+ * initRangoState (`{prefix}_{routerId}`, default prefix `rango-state`). The
15
+ * cookie jar is shared across tabs, so a per-request read IS the cross-tab
16
+ * value sync — no `storage` event is needed. An in-memory mirror is a
17
+ * write-through copy that is authoritative only when the cookie is unreadable
18
+ * (e.g. a sandboxed frame, or site data blocked wholesale): the failure
19
+ * direction is always toward freshness.
20
+ *
21
+ * Precedence is load-bearing: when `document.cookie` is readable, the
22
+ * per-request read wins; the mirror is a fallback, never a cache of the read.
23
+ * Caching the read across requests would reintroduce the staleness this
24
+ * mechanism removes.
16
25
  */
17
26
 
18
- const STORAGE_KEY = "rango-state";
27
+ import {
28
+ DEFAULT_STATE_COOKIE_PREFIX,
29
+ decodeStateValue,
30
+ getRawCookieValue,
31
+ mintStateValue,
32
+ serializeStateCookie,
33
+ } from "./cookie-name.js";
19
34
 
20
- // Module-level cache avoids hitting localStorage on every getRangoState() call.
21
- // Initialized from localStorage on first access or by initRangoState().
22
- let cachedState: string | null = null;
35
+ let cookieName: string = DEFAULT_STATE_COOKIE_PREFIX;
23
36
 
24
- // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
25
- // to localStorage, keeping cachedState fresh without polling.
26
- let storageListenerAttached = false;
37
+ let currentVersion = "0";
27
38
 
28
- function attachStorageListener(): void {
29
- if (storageListenerAttached || typeof window === "undefined") return;
30
- window.addEventListener("storage", (e) => {
31
- if (e.key !== STORAGE_KEY) return;
32
- cachedState = e.newValue;
33
- });
34
- storageListenerAttached = true;
35
- }
39
+ let mirror: string | null = null;
40
+ let cookieBacked = false;
41
+
42
+ let externalRotationObserver: ((value: string) => void) | null = null;
36
43
 
37
44
  /**
38
- * Initialize the Rango state key in localStorage.
39
- * Called once at app startup with the build version from the server.
40
- * If localStorage already has a key with matching version prefix, keeps it
41
- * (preserves invalidation state across refresh). Otherwise writes a new key.
45
+ * Register the observer invoked when a read detects an EXTERNAL rotation (a
46
+ * sibling tab, a server `Set-Cookie`, or a cookie clear). Self-rotations
47
+ * (invalidateRangoState) update the mirror synchronously and never fire it.
42
48
  */
43
- export function initRangoState(version: string): void {
44
- if (typeof window === "undefined") return;
49
+ export function setRangoStateObserver(
50
+ observer: ((value: string) => void) | null,
51
+ ): void {
52
+ externalRotationObserver = observer;
53
+ }
45
54
 
46
- attachStorageListener();
55
+ function notifyExternalRotation(value: string): void {
56
+ externalRotationObserver?.(value);
57
+ }
58
+
59
+ interface CookieRead {
60
+ /** False when there is no document or the read threw (sandboxed frame). */
61
+ readable: boolean;
62
+ /** The cookie value, or null when readable but absent. */
63
+ value: string | null;
64
+ }
47
65
 
66
+ function readCookie(name: string): CookieRead {
67
+ if (typeof document === "undefined") return { readable: false, value: null };
68
+ let raw: string;
48
69
  try {
49
- const existing = localStorage.getItem(STORAGE_KEY);
50
- if (existing) {
51
- const colonIdx = existing.indexOf(":");
52
- if (colonIdx > 0) {
53
- const existingVersion = existing.slice(0, colonIdx);
54
- if (existingVersion === version) {
55
- cachedState = existing;
56
- return;
57
- }
58
- }
59
- }
60
- // New version or first load
61
- const newState = `${version}:${Date.now()}`;
62
- localStorage.setItem(STORAGE_KEY, newState);
63
- cachedState = newState;
70
+ raw = document.cookie;
64
71
  } catch {
65
- // localStorage may be unavailable (private browsing in some browsers)
66
- cachedState = `${version}:${Date.now()}`;
72
+ return { readable: false, value: null };
73
+ }
74
+ return { readable: true, value: getRawCookieValue(raw, name) };
75
+ }
76
+
77
+ function writeCookie(name: string, value: string): void {
78
+ if (typeof document === "undefined") return;
79
+ const secure =
80
+ typeof location !== "undefined" && location.protocol === "https:";
81
+ try {
82
+ document.cookie = serializeStateCookie(name, value, secure);
83
+ } catch {}
84
+ }
85
+
86
+ function mintValue(): string {
87
+ return mintStateValue(currentVersion, mirror);
88
+ }
89
+
90
+ /**
91
+ * Initialize the Rango state cookie at app startup. `version` is the build
92
+ * version; `stateCookieName` is the server-resolved cookie name from payload
93
+ * metadata (falls back to the bare default prefix when a payload arrives
94
+ * without it). Keeps an existing matching-version cookie (preserves the cache
95
+ * key across reloads); mints fresh on a version change or a missing cookie.
96
+ */
97
+ export function initRangoState(
98
+ version: string,
99
+ stateCookieName?: string,
100
+ ): void {
101
+ currentVersion = version;
102
+ cookieName = stateCookieName || DEFAULT_STATE_COOKIE_PREFIX;
103
+ cleanupLegacyStorage();
104
+
105
+ const read = readCookie(cookieName);
106
+ if (!read.readable) {
107
+ // Cookies unreadable: the mirror is the source of truth for this session.
108
+ mirror = mintValue();
109
+ cookieBacked = false;
110
+ return;
67
111
  }
112
+ if (read.value !== null) {
113
+ const decoded = decodeStateValue(read.value);
114
+ if (decoded && decoded.version === version) {
115
+ // Keep: a matching-version cookie survives the reload warm.
116
+ mirror = read.value;
117
+ cookieBacked = true;
118
+ return;
119
+ }
120
+ }
121
+ // Absent, malformed, or a version change (deploy): mint fresh and write.
122
+ mirror = mintValue();
123
+ cookieBacked = false;
124
+ writeCookie(cookieName, mirror);
68
125
  }
69
126
 
70
127
  /**
71
- * Get the current Rango state key value.
72
- * Used as the `X-Rango-State` header value for prefetch and navigation requests.
128
+ * Get the current Rango state value, used as the `X-Rango-State` header on
129
+ * prefetch and navigation requests. Reads the cookie every call (the read is
130
+ * the cross-tab sync channel) and reconciles the mirror.
73
131
  */
74
132
  export function getRangoState(): string {
75
- if (cachedState) return cachedState;
133
+ const read = readCookie(cookieName);
76
134
 
77
- if (typeof window === "undefined") return "0:0";
135
+ if (!read.readable) {
136
+ // Mirror authoritative when the jar is unreadable.
137
+ return mirror ?? "0:0";
138
+ }
78
139
 
79
- try {
80
- const stored = localStorage.getItem(STORAGE_KEY);
81
- if (stored) {
82
- cachedState = stored;
83
- return stored;
140
+ if (read.value !== null) {
141
+ if (read.value !== mirror) {
142
+ // External rotation (sibling tab / server Set-Cookie): adopt it. The
143
+ // mirror update makes this idempotent across a burst of reads.
144
+ mirror = read.value;
145
+ cookieBacked = true;
146
+ notifyExternalRotation(read.value);
147
+ } else {
148
+ cookieBacked = true;
84
149
  }
85
- } catch {
86
- // Fallback for unavailable localStorage
150
+ return read.value;
87
151
  }
88
152
 
89
- return "0:0";
153
+ // Readable but absent.
154
+ if (cookieBacked) {
155
+ // present -> absent: an external clear. Mint fresh, write back, and notify
156
+ // once (cookieBacked flips to false so we don't re-fire on the next read).
157
+ mirror = mintValue();
158
+ cookieBacked = false;
159
+ writeCookie(cookieName, mirror);
160
+ notifyExternalRotation(mirror);
161
+ } else if (mirror === null) {
162
+ // First access with no cookie yet (pre-boot): mint silently — there is
163
+ // nothing to invalidate.
164
+ mirror = mintValue();
165
+ writeCookie(cookieName, mirror);
166
+ }
167
+ return mirror;
90
168
  }
91
169
 
92
170
  /**
93
- * Invalidate the Rango state key. Called when server actions mutate data.
94
- * Updates the timestamp portion while keeping the version prefix.
95
- * The new value takes effect immediately for all subsequent fetches,
96
- * causing Vary mismatches with previously cached responses.
171
+ * Invalidate the Rango state (self-rotation). Called when the client clears its
172
+ * prefetch caches (e.g. via the server-action bridge). Rotates the timestamp,
173
+ * keeps the version, writes the cookie, and updates the mirror synchronously so
174
+ * the external-rotation observer is NOT triggered by our own write.
97
175
  */
98
176
  export function invalidateRangoState(): void {
99
- const current = getRangoState();
100
- const colonIdx = current.indexOf(":");
101
- const version = colonIdx > 0 ? current.slice(0, colonIdx) : "0";
102
- const newState = `${version}:${Date.now()}`;
103
- cachedState = newState;
104
-
105
- if (typeof window === "undefined") return;
177
+ mirror = mintValue();
178
+ cookieBacked = false;
179
+ writeCookie(cookieName, mirror);
180
+ }
106
181
 
182
+ function cleanupLegacyStorage(): void {
183
+ if (typeof localStorage === "undefined") return;
107
184
  try {
108
- localStorage.setItem(STORAGE_KEY, newState);
109
- } catch {
110
- // Silently handle localStorage errors
111
- }
185
+ const toRemove: string[] = [];
186
+ for (let i = 0; i < localStorage.length; i++) {
187
+ const key = localStorage.key(i);
188
+ if (key === "rango-state" || (key && key.startsWith("rango-state:"))) {
189
+ toRemove.push(key);
190
+ }
191
+ }
192
+ for (const key of toRemove) localStorage.removeItem(key);
193
+ } catch {}
112
194
  }
@@ -5,6 +5,7 @@ import React, {
5
5
  useCallback,
6
6
  useContext,
7
7
  useEffect,
8
+ useMemo,
8
9
  useRef,
9
10
  type ForwardRefExoticComponent,
10
11
  type RefAttributes,
@@ -32,13 +33,12 @@ export type LinkState =
32
33
  | StateOrGetter<Record<string, unknown>>;
33
34
 
34
35
  import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
36
+ import { getAppVersion } from "../app-version.js";
35
37
  import {
36
38
  observeForPrefetch,
37
39
  unobserveForPrefetch,
38
40
  } from "../prefetch/observer.js";
39
41
 
40
- // Touch device detection for adaptive strategy.
41
- // Checked once at module load (Link.tsx is "use client", runs only in browser).
42
42
  const isTouchDevice =
43
43
  typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
44
44
 
@@ -95,6 +95,31 @@ export interface LinkProps extends Omit<
95
95
  * @default "none"
96
96
  */
97
97
  prefetch?: PrefetchStrategy;
98
+ /**
99
+ * Opt-in override for the prefetch cache scope.
100
+ *
101
+ * The default cache is source-agnostic: one shared entry per target,
102
+ * keyed on Rango state + target URL. This is correct for routes whose
103
+ * response shape doesn't depend on where the user navigates from.
104
+ *
105
+ * Set `":source"` when this Link's response would legitimately differ
106
+ * based on the source page — typically when the target route (or one
107
+ * of its layouts) uses a custom `revalidate()` handler that reads
108
+ * `currentUrl` / `currentParams`, and the wildcard entry would
109
+ * therefore serve the wrong diff to a navigation from a different
110
+ * source.
111
+ *
112
+ * Intercept responses are auto-scoped to the source via a server-side
113
+ * tag, so `":source"` is only needed for custom revalidation logic.
114
+ *
115
+ * @example
116
+ * ```tsx
117
+ * // Route uses a `revalidate()` that branches on currentUrl — opt in
118
+ * // so prefetches don't bleed across source pages.
119
+ * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
120
+ * ```
121
+ */
122
+ prefetchKey?: ":source";
98
123
  /**
99
124
  * State to pass to history.pushState/replaceState.
100
125
  * Accessible via useLocationState() hook.
@@ -182,6 +207,7 @@ export const Link: ForwardRefExoticComponent<
182
207
  reloadDocument = false,
183
208
  revalidate,
184
209
  prefetch = "none",
210
+ prefetchKey,
185
211
  state,
186
212
  children,
187
213
  onClick,
@@ -192,6 +218,16 @@ export const Link: ForwardRefExoticComponent<
192
218
  const ctx = useContext(NavigationStoreContext);
193
219
  const isExternal = isExternalUrl(to);
194
220
 
221
+ // Auto-prefix with basename for app-local paths.
222
+ // Skip if external, already prefixed, or not a root-relative path.
223
+ const resolvedTo = useMemo(() => {
224
+ if (isExternal) return to;
225
+ const bn = ctx?.basename;
226
+ if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
227
+ return to;
228
+ return to === "/" ? bn : bn + to;
229
+ }, [to, isExternal, ctx?.basename]);
230
+
195
231
  // Resolve adaptive: viewport on touch devices, hover on pointer devices
196
232
  const resolvedStrategy =
197
233
  prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
@@ -273,9 +309,23 @@ export const Link: ForwardRefExoticComponent<
273
309
  resolvedState = currentState;
274
310
  }
275
311
 
276
- ctx.navigate(to, { replace, scroll, state: resolvedState, revalidate });
312
+ ctx.navigate(resolvedTo, {
313
+ replace,
314
+ scroll,
315
+ state: resolvedState,
316
+ revalidate,
317
+ });
277
318
  },
278
- [to, isExternal, reloadDocument, replace, scroll, revalidate, ctx, onClick],
319
+ [
320
+ resolvedTo,
321
+ isExternal,
322
+ reloadDocument,
323
+ replace,
324
+ scroll,
325
+ revalidate,
326
+ ctx,
327
+ onClick,
328
+ ],
279
329
  );
280
330
 
281
331
  const handleMouseEnter = useCallback(() => {
@@ -289,9 +339,15 @@ export const Link: ForwardRefExoticComponent<
289
339
  // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
290
340
  // deduplicates if the viewport prefetch already completed.
291
341
  const segmentState = ctx.store.getSegmentState();
292
- prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
342
+ prefetchDirect(
343
+ resolvedTo,
344
+ segmentState.currentSegmentIds,
345
+ getAppVersion(),
346
+ ctx.store.getRouterId?.(),
347
+ prefetchKey,
348
+ );
293
349
  }
294
- }, [resolvedStrategy, to, isExternal, ctx]);
350
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
295
351
 
296
352
  // Viewport/render prefetch: waits for idle before starting,
297
353
  // uses concurrency-limited queue to avoid flooding.
@@ -308,7 +364,13 @@ export const Link: ForwardRefExoticComponent<
308
364
  const triggerPrefetch = () => {
309
365
  if (cancelled) return;
310
366
  const segmentState = ctx.store.getSegmentState();
311
- prefetchQueued(to, segmentState.currentSegmentIds, ctx.version);
367
+ prefetchQueued(
368
+ resolvedTo,
369
+ segmentState.currentSegmentIds,
370
+ getAppVersion(),
371
+ ctx.store.getRouterId?.(),
372
+ prefetchKey,
373
+ );
312
374
  };
313
375
 
314
376
  // Schedule prefetch only when the app is idle (no navigation/streaming).
@@ -347,12 +409,12 @@ export const Link: ForwardRefExoticComponent<
347
409
  unobserveForPrefetch(observedElement);
348
410
  }
349
411
  };
350
- }, [resolvedStrategy, to, isExternal, ctx]);
412
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
351
413
 
352
414
  return (
353
415
  <a
354
416
  ref={setRef}
355
- href={to}
417
+ href={resolvedTo}
356
418
  onClick={handleClick}
357
419
  onMouseEnter={handleMouseEnter}
358
420
  data-link-component
@@ -362,7 +424,7 @@ export const Link: ForwardRefExoticComponent<
362
424
  data-revalidate={revalidate === false ? "false" : undefined}
363
425
  {...props}
364
426
  >
365
- <LinkContext.Provider value={to}>{children}</LinkContext.Provider>
427
+ <LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
366
428
  </a>
367
429
  );
368
430
  });
@@ -28,6 +28,8 @@ import { NonceContext } from "./nonce-context.js";
28
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
+ import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
+ import { debugLog } from "../logging.js";
31
33
 
32
34
  /**
33
35
  * Process handles from an async generator, updating the event controller
@@ -46,10 +48,22 @@ async function processHandles(
46
48
  store: NavigationStore;
47
49
  matched?: string[];
48
50
  isPartial?: boolean;
51
+ /** Server's `resolvedIds`: every segment re-resolved this request,
52
+ * including null-component ones excluded from `diff`/`segments`.
53
+ * Drives cleanup of stale handle buckets when a re-resolved segment
54
+ * pushed nothing. */
55
+ resolvedIds?: string[];
49
56
  historyKey: string;
50
57
  },
51
58
  ): Promise<void> {
52
- const { eventController, store, matched, isPartial, historyKey } = opts;
59
+ const {
60
+ eventController,
61
+ store,
62
+ matched,
63
+ isPartial,
64
+ resolvedIds,
65
+ historyKey,
66
+ } = opts;
53
67
 
54
68
  let yieldCount = 0;
55
69
  for await (const handleData of handlesGenerator) {
@@ -57,14 +71,14 @@ async function processHandles(
57
71
  // This prevents handle data from cancelled navigations polluting
58
72
  // the current route's breadcrumbs (e.g., quick popstate after clicking a link).
59
73
  if (historyKey !== store.getHistoryKey()) {
60
- console.log(
74
+ debugLog(
61
75
  "[NavigationProvider] Stopping handle processing - user navigated away",
62
76
  );
63
77
  return;
64
78
  }
65
79
 
66
80
  yieldCount++;
67
- eventController.setHandleData(handleData, matched, isPartial);
81
+ eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
68
82
  }
69
83
 
70
84
  // Check again before final updates
@@ -72,12 +86,11 @@ async function processHandles(
72
86
  return;
73
87
  }
74
88
 
75
- // For partial updates where the generator yielded nothing (cached handlers),
76
- // we still need to update the segment order to clean up stale handle data.
77
- // This happens when navigating away from a route - the handlers for the new
78
- // route might not push any breadcrumbs, but we still need to remove the old ones.
89
+ // For partial updates where the generator yielded nothing (every
90
+ // re-resolved handler pushed nothing), still call setHandleData so the
91
+ // cleanup pass can clear out stale buckets for those segments.
79
92
  if (yieldCount === 0 && matched) {
80
- eventController.setHandleData({}, matched, true);
93
+ eventController.setHandleData({}, matched, true, resolvedIds);
81
94
  }
82
95
 
83
96
  // After handles processing completes, update the cache's handleData.
@@ -133,10 +146,25 @@ export interface NavigationProviderProps {
133
146
  warmupEnabled?: boolean;
134
147
 
135
148
  /**
136
- * App version from server payload (stable, immutable).
137
- * Forwarded to prefetch requests for version mismatch detection.
149
+ * App version from server payload.
150
+ * Used only as a fallback when `appShellRef` is not supplied.
138
151
  */
139
152
  version?: string;
153
+
154
+ /**
155
+ * URL prefix for all routes (from createRouter({ basename })).
156
+ * Used only as a fallback when `appShellRef` is not supplied.
157
+ */
158
+ basename?: string;
159
+
160
+ /**
161
+ * App-shell ref. When provided, the context's `basename` and `version` are
162
+ * read through it (live getters) so they don't close over a stale snapshot or
163
+ * invalidate the memoized context value. The shell is set once at init and is
164
+ * not swapped within a session — a cross-app navigation is a full document
165
+ * load (X-RSC-Reload), so the target app establishes its own shell on load.
166
+ */
167
+ appShellRef?: AppShellRef;
140
168
  }
141
169
 
142
170
  /**
@@ -169,6 +197,8 @@ export function NavigationProvider({
169
197
  initialTheme,
170
198
  warmupEnabled,
171
199
  version,
200
+ basename,
201
+ appShellRef,
172
202
  }: NavigationProviderProps): ReactNode {
173
203
  // Track current payload for rendering (this triggers re-renders)
174
204
  const [payload, setPayload] = useState(initialPayload);
@@ -190,17 +220,35 @@ export function NavigationProvider({
190
220
  await bridge.refresh();
191
221
  }, []);
192
222
 
193
- // Context value is stable (store, eventController, navigate, refresh never change)
194
- const contextValue = useMemo<NavigationStoreContextValue>(
195
- () => ({
223
+ // basename/version are always read through a shell ref so the context value
224
+ // has a single shape. Both are set once: a supplied appShellRef is seeded
225
+ // from the init payload (a cross-app navigation reloads, so it is not swapped
226
+ // in-session), and the standalone fallback wraps the mount-time props.
227
+ const fallbackShellRef = useRef<AppShellRef | null>(null);
228
+ if (!fallbackShellRef.current) {
229
+ fallbackShellRef.current = createAppShellRef({ basename, version });
230
+ }
231
+ const shellRef = appShellRef ?? fallbackShellRef.current;
232
+
233
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
234
+ const value = {
196
235
  store,
197
236
  eventController,
198
237
  navigate,
199
238
  refresh,
200
- version,
201
- }),
202
- [],
203
- );
239
+ } as NavigationStoreContextValue;
240
+ Object.defineProperty(value, "basename", {
241
+ configurable: true,
242
+ enumerable: true,
243
+ get: () => shellRef.get().basename,
244
+ });
245
+ Object.defineProperty(value, "version", {
246
+ configurable: true,
247
+ enumerable: true,
248
+ get: () => shellRef.get().version,
249
+ });
250
+ return value;
251
+ }, []);
204
252
 
205
253
  // Connection warmup: keep TLS alive after idle periods.
206
254
  // After 60s of no user interaction, marks connection as "cold".
@@ -338,8 +386,12 @@ export function NavigationProvider({
338
386
  metadata: update.metadata,
339
387
  });
340
388
 
341
- // Update route params
342
- eventController.setParams(update.metadata.params ?? {});
389
+ // Update route params. Only reset when the server actually sends a params
390
+ // map — an absent `params` field means "no change" (e.g., legacy action
391
+ // responses that omitted params). Explicit `{}` still clears correctly.
392
+ if (update.metadata.params !== undefined) {
393
+ eventController.setParams(update.metadata.params);
394
+ }
343
395
 
344
396
  // Update handle data progressively as it streams in
345
397
  if (update.metadata.handles) {
@@ -352,24 +404,20 @@ export function NavigationProvider({
352
404
  store,
353
405
  matched: update.metadata.matched,
354
406
  isPartial: update.metadata.isPartial,
407
+ resolvedIds: update.metadata.resolvedIds,
355
408
  historyKey,
356
409
  }).catch((err) =>
357
410
  console.error("[NavigationProvider] Error consuming handles:", err),
358
411
  );
359
- } else if (update.metadata.cachedHandleData) {
360
- // For back/forward navigation from cache, restore the cached handleData
361
- // This restores breadcrumbs to the exact state they were when the page was cached
362
- eventController.setHandleData(
363
- update.metadata.cachedHandleData,
364
- update.metadata.matched,
365
- false, // full replace - restore entire cached state
366
- );
367
412
  } else if (update.metadata.matched) {
368
- // For cached navigations without handleData, update segmentOrder to clean up stale data
413
+ // cachedHandleData present -> full restore (back/forward); absent ->
414
+ // partial cleanup of segments no longer matched.
415
+ const cached = update.metadata.cachedHandleData;
369
416
  eventController.setHandleData(
370
- {}, // Empty data - all existing data not in matched will be cleaned up
417
+ cached ?? {},
371
418
  update.metadata.matched,
372
- true, // partial update - will clean up segments not in matched
419
+ cached === undefined,
420
+ cached === undefined ? update.metadata.resolvedIds : undefined,
373
421
  );
374
422
  }
375
423
  });
@@ -391,7 +439,11 @@ export function NavigationProvider({
391
439
  // Build the content tree
392
440
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
393
441
 
394
- // Wrap with ThemeProvider when theme is enabled
442
+ // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
443
+ // document-lifetime: its config comes from the initial load and persists for
444
+ // the session. It sits above the segment tree and is not remounted in-session;
445
+ // a cross-app navigation is a full document load (X-RSC-Reload), so the target
446
+ // app's theme config takes effect on its own load.
395
447
  if (themeConfig) {
396
448
  content = (
397
449
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
@@ -14,17 +14,21 @@ export interface ScrollRestorationProps {
14
14
  * Return location.pathname to restore scroll based on path
15
15
  * (useful for keeping scroll position on the same page).
16
16
  *
17
+ * Provide a stable reference: a module-level function or one wrapped in
18
+ * useCallback. The init effect re-runs when getKey's identity changes, and
19
+ * teardown clears in-memory scroll positions — a fresh inline arrow on every
20
+ * parent render would discard unpersisted positions mid-session.
21
+ *
17
22
  * @example
18
23
  * ```tsx
24
+ * // Stable module-level getKey (recommended)
25
+ * const byPathname = (location) => location.pathname;
26
+ *
19
27
  * // Restore based on pathname (same URL = same scroll)
20
- * <ScrollRestoration
21
- * getKey={(location) => location.pathname}
22
- * />
28
+ * <ScrollRestoration getKey={byPathname} />
23
29
  *
24
30
  * // Restore based on unique history entry (default)
25
- * <ScrollRestoration
26
- * getKey={(location) => location.key}
27
- * />
31
+ * // <ScrollRestoration /> — omit getKey to use location.key
28
32
  * ```
29
33
  */
30
34
  getKey?: (location: {