@rangojs/router 0.0.0-experimental.bd6e11bc → 0.0.0-experimental.bdaf10aa

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 (411) hide show
  1. package/AGENTS.md +8 -4
  2. package/README.md +296 -887
  3. package/dist/bin/rango.js +459 -91
  4. package/dist/testing/vitest.js +36 -2
  5. package/dist/vite/index.js +1708 -414
  6. package/package.json +35 -10
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +82 -5
  9. package/skills/bundle-analysis/SKILL.md +2 -2
  10. package/skills/cache-guide/SKILL.md +14 -9
  11. package/skills/caching/SKILL.md +221 -12
  12. package/skills/catalog.json +271 -0
  13. package/skills/comparison/SKILL.md +50 -0
  14. package/skills/comparison/agents/openai.yaml +4 -0
  15. package/skills/comparison/references/framework-comparison.md +837 -0
  16. package/skills/composability/SKILL.md +83 -2
  17. package/skills/css/SKILL.md +76 -0
  18. package/skills/debug-manifest/SKILL.md +5 -3
  19. package/skills/defer-hydration/SKILL.md +235 -0
  20. package/skills/document-cache/SKILL.md +11 -3
  21. package/skills/fonts/SKILL.md +1 -1
  22. package/skills/handler-use/SKILL.md +9 -9
  23. package/skills/hooks/SKILL.md +73 -900
  24. package/skills/hooks/data.md +273 -0
  25. package/skills/hooks/handle-and-actions.md +103 -0
  26. package/skills/hooks/navigation.md +110 -0
  27. package/skills/hooks/outlets.md +41 -0
  28. package/skills/hooks/state.md +228 -0
  29. package/skills/hooks/urls.md +135 -0
  30. package/skills/host-router/SKILL.md +84 -7
  31. package/skills/i18n/SKILL.md +1 -1
  32. package/skills/intercept/SKILL.md +51 -17
  33. package/skills/layout/SKILL.md +38 -16
  34. package/skills/links/SKILL.md +1 -1
  35. package/skills/loader/SKILL.md +48 -20
  36. package/skills/middleware/SKILL.md +11 -5
  37. package/skills/migrate-nextjs/SKILL.md +203 -20
  38. package/skills/migrate-react-router/SKILL.md +59 -675
  39. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  40. package/skills/migrate-react-router/component-migration.md +196 -0
  41. package/skills/migrate-react-router/data-and-actions.md +225 -0
  42. package/skills/migrate-react-router/route-mapping.md +271 -0
  43. package/skills/mime-routes/SKILL.md +3 -3
  44. package/skills/observability/SKILL.md +70 -5
  45. package/skills/parallel/SKILL.md +32 -8
  46. package/skills/ppr/SKILL.md +622 -0
  47. package/skills/prerender/SKILL.md +59 -28
  48. package/skills/rango/SKILL.md +124 -50
  49. package/skills/response-routes/SKILL.md +78 -46
  50. package/skills/route/SKILL.md +85 -6
  51. package/skills/router-setup/SKILL.md +41 -6
  52. package/skills/scripts/SKILL.md +179 -0
  53. package/skills/server-actions/SKILL.md +28 -3
  54. package/skills/shell-manifest/SKILL.md +185 -0
  55. package/skills/streams-and-websockets/SKILL.md +1 -1
  56. package/skills/tailwind/SKILL.md +28 -4
  57. package/skills/testing/SKILL.md +68 -654
  58. package/skills/testing/bindings.md +103 -0
  59. package/skills/testing/cache-prerender.md +127 -0
  60. package/skills/testing/client-components.md +124 -0
  61. package/skills/testing/e2e-parity.md +125 -0
  62. package/skills/testing/flight.md +91 -0
  63. package/skills/testing/handles.md +131 -0
  64. package/skills/testing/loader.md +128 -0
  65. package/skills/testing/middleware.md +99 -0
  66. package/skills/testing/render-handler.md +122 -0
  67. package/skills/testing/response-routes.md +95 -0
  68. package/skills/testing/reverse-and-types.md +85 -0
  69. package/skills/testing/server-actions.md +107 -0
  70. package/skills/testing/server-tree.md +128 -0
  71. package/skills/testing/setup.md +123 -0
  72. package/skills/theme/SKILL.md +1 -1
  73. package/skills/typesafety/SKILL.md +45 -918
  74. package/skills/typesafety/env-and-bindings.md +254 -0
  75. package/skills/typesafety/generated-files-and-cli.md +335 -0
  76. package/skills/typesafety/params-and-search.md +153 -0
  77. package/skills/typesafety/route-types.md +209 -0
  78. package/skills/use-cache/SKILL.md +47 -17
  79. package/skills/vercel/SKILL.md +128 -0
  80. package/skills/view-transitions/SKILL.md +44 -1
  81. package/src/__augment-tests__/augmented.check.ts +2 -3
  82. package/src/__internal.ts +0 -65
  83. package/src/browser/action-coordinator.ts +1 -1
  84. package/src/browser/action-fence.ts +47 -0
  85. package/src/browser/app-shell.ts +14 -27
  86. package/src/browser/connection-warmup.ts +134 -0
  87. package/src/browser/cookie-name.ts +140 -0
  88. package/src/browser/event-controller.ts +178 -100
  89. package/src/browser/invalidate-client-cache.ts +52 -0
  90. package/src/browser/logging.ts +28 -0
  91. package/src/browser/merge-segment-loaders.ts +6 -4
  92. package/src/browser/navigation-bridge.ts +81 -68
  93. package/src/browser/navigation-client.ts +115 -70
  94. package/src/browser/navigation-store-handle.ts +38 -0
  95. package/src/browser/navigation-store.ts +153 -88
  96. package/src/browser/navigation-transaction.ts +0 -32
  97. package/src/browser/network-error-handler.ts +34 -7
  98. package/src/browser/partial-update.ts +157 -144
  99. package/src/browser/prefetch/cache.ts +148 -81
  100. package/src/browser/prefetch/fetch.ts +231 -51
  101. package/src/browser/prefetch/queue.ts +25 -7
  102. package/src/browser/rango-state.ts +157 -115
  103. package/src/browser/react/Link.tsx +40 -7
  104. package/src/browser/react/NavigationProvider.tsx +140 -99
  105. package/src/browser/react/ScrollRestoration.tsx +10 -6
  106. package/src/browser/react/filter-segment-order.ts +17 -2
  107. package/src/browser/react/index.ts +0 -51
  108. package/src/browser/react/location-state-shared.ts +14 -15
  109. package/src/browser/react/location-state.ts +0 -1
  110. package/src/browser/react/use-action.ts +6 -15
  111. package/src/browser/react/use-handle.ts +0 -5
  112. package/src/browser/react/use-href.tsx +8 -1
  113. package/src/browser/react/use-link-status.ts +33 -8
  114. package/src/browser/react/use-navigation.ts +10 -5
  115. package/src/browser/react/use-params.ts +0 -2
  116. package/src/browser/react/use-router.ts +6 -4
  117. package/src/browser/react/use-search-params.ts +0 -5
  118. package/src/browser/react/use-segments.ts +0 -13
  119. package/src/browser/response-adapter.ts +74 -8
  120. package/src/browser/rsc-router.tsx +97 -22
  121. package/src/browser/scroll-restoration.ts +15 -8
  122. package/src/browser/segment-reconciler.ts +31 -21
  123. package/src/browser/server-action-bridge.ts +216 -38
  124. package/src/browser/types.ts +94 -22
  125. package/src/browser/validate-redirect-origin.ts +43 -16
  126. package/src/build/generate-manifest.ts +155 -131
  127. package/src/build/generate-route-types.ts +1 -1
  128. package/src/build/index.ts +11 -5
  129. package/src/build/prefix-tree-utils.ts +123 -0
  130. package/src/build/route-trie.ts +152 -22
  131. package/src/build/route-types/ast-route-extraction.ts +15 -8
  132. package/src/build/route-types/codegen.ts +12 -1
  133. package/src/build/route-types/include-resolution.ts +455 -61
  134. package/src/build/route-types/param-extraction.ts +6 -3
  135. package/src/build/route-types/per-module-writer.ts +15 -2
  136. package/src/build/route-types/router-processing.ts +77 -41
  137. package/src/build/route-types/source-scan.ts +105 -7
  138. package/src/build/runtime-discovery.ts +4 -1
  139. package/src/cache/cache-error.ts +104 -0
  140. package/src/cache/cache-key-utils.ts +58 -13
  141. package/src/cache/cache-policy.ts +108 -34
  142. package/src/cache/cache-runtime.ts +454 -101
  143. package/src/cache/cache-scope.ts +159 -54
  144. package/src/cache/cache-tag.ts +149 -0
  145. package/src/cache/cf/cf-base64.ts +33 -0
  146. package/src/cache/cf/cf-cache-constants.ts +127 -0
  147. package/src/cache/cf/cf-cache-store.ts +2170 -377
  148. package/src/cache/cf/cf-cache-types.ts +349 -0
  149. package/src/cache/cf/cf-kv-utils.ts +46 -0
  150. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  151. package/src/cache/cf/index.ts +6 -16
  152. package/src/cache/document-cache.ts +126 -41
  153. package/src/cache/handle-snapshot.ts +70 -0
  154. package/src/cache/index.ts +23 -20
  155. package/src/cache/memory-segment-store.ts +243 -37
  156. package/src/cache/profile-registry.ts +46 -31
  157. package/src/cache/read-through-swr.ts +56 -12
  158. package/src/cache/segment-codec.ts +13 -21
  159. package/src/cache/shell-snapshot.ts +417 -0
  160. package/src/cache/tag-invalidation.ts +230 -0
  161. package/src/cache/types.ts +194 -99
  162. package/src/cache/vercel/index.ts +11 -0
  163. package/src/cache/vercel/vercel-cache-store.ts +1132 -0
  164. package/src/client.rsc.tsx +39 -22
  165. package/src/client.tsx +28 -58
  166. package/src/cloudflare/index.ts +11 -0
  167. package/src/cloudflare/tracing.ts +108 -0
  168. package/src/component-utils.ts +19 -0
  169. package/src/components/DefaultDocument.tsx +8 -2
  170. package/src/context-var.ts +13 -1
  171. package/src/decode-loader-results.ts +18 -2
  172. package/src/defer.ts +185 -0
  173. package/src/deps/ssr.ts +0 -1
  174. package/src/encode-kv.ts +49 -0
  175. package/src/errors.ts +0 -3
  176. package/src/escape-script.ts +52 -0
  177. package/src/handle.ts +57 -40
  178. package/src/handles/MetaTags.tsx +24 -53
  179. package/src/handles/Scripts.tsx +183 -0
  180. package/src/handles/breadcrumbs.ts +35 -8
  181. package/src/handles/deferred-resolution.ts +127 -0
  182. package/src/handles/is-thenable.ts +18 -0
  183. package/src/handles/meta.ts +14 -40
  184. package/src/handles/script.ts +244 -0
  185. package/src/host/cookie-handler.ts +9 -60
  186. package/src/host/errors.ts +13 -22
  187. package/src/host/index.ts +7 -0
  188. package/src/host/pattern-matcher.ts +23 -52
  189. package/src/host/router.ts +1 -65
  190. package/src/host/testing.ts +40 -27
  191. package/src/host/types.ts +6 -2
  192. package/src/href-client.ts +7 -12
  193. package/src/index.rsc.ts +88 -8
  194. package/src/index.ts +90 -16
  195. package/src/internal-debug.ts +11 -10
  196. package/src/loader.rsc.ts +19 -9
  197. package/src/loader.ts +12 -4
  198. package/src/outlet-provider.tsx +1 -5
  199. package/src/prerender/param-hash.ts +16 -16
  200. package/src/prerender/store.ts +32 -37
  201. package/src/prerender.ts +75 -7
  202. package/src/redirect-origin.ts +114 -0
  203. package/src/regex-escape.ts +8 -0
  204. package/src/render-error-thrower.tsx +20 -0
  205. package/src/response-utils.ts +25 -0
  206. package/src/root-error-boundary.tsx +1 -19
  207. package/src/route-content-wrapper.tsx +13 -49
  208. package/src/route-definition/dsl-helpers.ts +60 -53
  209. package/src/route-definition/helper-factories.ts +0 -2
  210. package/src/route-definition/helpers-types.ts +46 -46
  211. package/src/route-definition/index.ts +1 -2
  212. package/src/route-definition/redirect.ts +44 -11
  213. package/src/route-definition/resolve-handler-use.ts +6 -1
  214. package/src/route-definition/use-item-types.ts +3 -6
  215. package/src/route-map-builder.ts +41 -20
  216. package/src/route-types.ts +0 -5
  217. package/src/router/content-negotiation.ts +58 -23
  218. package/src/router/error-handling.ts +44 -17
  219. package/src/router/find-match.ts +129 -30
  220. package/src/router/handler-context.ts +6 -1
  221. package/src/router/instrument.ts +355 -0
  222. package/src/router/intercept-resolution.ts +35 -2
  223. package/src/router/lazy-includes.ts +79 -56
  224. package/src/router/loader-resolution.ts +151 -73
  225. package/src/router/logging.ts +0 -6
  226. package/src/router/manifest.ts +74 -40
  227. package/src/router/match-api.ts +76 -52
  228. package/src/router/match-context.ts +0 -22
  229. package/src/router/match-handlers.ts +181 -178
  230. package/src/router/match-middleware/background-revalidation.ts +40 -24
  231. package/src/router/match-middleware/cache-lookup.ts +115 -194
  232. package/src/router/match-middleware/cache-store.ts +61 -50
  233. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  234. package/src/router/match-middleware/segment-resolution.ts +0 -22
  235. package/src/router/match-pipelines.ts +1 -42
  236. package/src/router/match-result.ts +36 -67
  237. package/src/router/metrics.ts +0 -34
  238. package/src/router/middleware-types.ts +0 -116
  239. package/src/router/middleware.ts +231 -120
  240. package/src/router/navigation-snapshot.ts +7 -56
  241. package/src/router/params-util.ts +23 -0
  242. package/src/router/parse-pattern.ts +115 -0
  243. package/src/router/pattern-matching.ts +99 -152
  244. package/src/router/prefetch-cache-ttl.ts +51 -0
  245. package/src/router/prefetch-limits.ts +37 -0
  246. package/src/router/prerender-match.ts +111 -66
  247. package/src/router/preview-match.ts +3 -1
  248. package/src/router/request-classification.ts +47 -42
  249. package/src/router/revalidation.ts +75 -81
  250. package/src/router/route-snapshot.ts +14 -3
  251. package/src/router/router-context.ts +6 -29
  252. package/src/router/router-interfaces.ts +70 -8
  253. package/src/router/router-options.ts +126 -4
  254. package/src/router/segment-resolution/fresh.ts +104 -80
  255. package/src/router/segment-resolution/helpers.ts +86 -6
  256. package/src/router/segment-resolution/loader-cache.ts +155 -39
  257. package/src/router/segment-resolution/loader-mask.ts +60 -0
  258. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  259. package/src/router/segment-resolution/mask-nested.ts +83 -0
  260. package/src/router/segment-resolution/revalidation.ts +215 -304
  261. package/src/router/segment-resolution/static-store.ts +19 -5
  262. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  263. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  264. package/src/router/segment-resolution.ts +5 -1
  265. package/src/router/segment-wrappers.ts +6 -5
  266. package/src/router/state-cookie-name.ts +33 -0
  267. package/src/router/substitute-pattern-params.ts +54 -35
  268. package/src/router/telemetry-otel.ts +160 -200
  269. package/src/router/telemetry.ts +9 -23
  270. package/src/router/timeout.ts +0 -20
  271. package/src/router/tracing.ts +215 -0
  272. package/src/router/trie-matching.ts +171 -64
  273. package/src/router/types.ts +1 -63
  274. package/src/router/url-params.ts +13 -5
  275. package/src/router.ts +119 -48
  276. package/src/rsc/full-payload.ts +70 -0
  277. package/src/rsc/handler-context.ts +1 -0
  278. package/src/rsc/handler.ts +267 -152
  279. package/src/rsc/helpers.ts +78 -4
  280. package/src/rsc/index.ts +1 -4
  281. package/src/rsc/json-route-result.ts +38 -0
  282. package/src/rsc/loader-fetch.ts +114 -38
  283. package/src/rsc/manifest-init.ts +29 -42
  284. package/src/rsc/nonce.ts +10 -1
  285. package/src/rsc/origin-guard.ts +11 -15
  286. package/src/rsc/progressive-enhancement.ts +120 -13
  287. package/src/rsc/redirect-guard.ts +100 -0
  288. package/src/rsc/response-cache-serve.ts +238 -0
  289. package/src/rsc/response-error.ts +79 -12
  290. package/src/rsc/response-route-handler.ts +58 -141
  291. package/src/rsc/rsc-rendering.ts +492 -49
  292. package/src/rsc/runtime-warnings.ts +14 -0
  293. package/src/rsc/server-action.ts +268 -82
  294. package/src/rsc/shell-capture.ts +1190 -0
  295. package/src/rsc/shell-serve.ts +181 -0
  296. package/src/rsc/transition-gate.ts +89 -0
  297. package/src/rsc/types.ts +45 -3
  298. package/src/runtime-env.ts +18 -0
  299. package/src/search-params.ts +31 -26
  300. package/src/segment-loader-promise.ts +49 -4
  301. package/src/segment-system.tsx +260 -95
  302. package/src/server/context.ts +99 -9
  303. package/src/server/cookie-parse.ts +32 -0
  304. package/src/server/cookie-store.ts +125 -2
  305. package/src/server/handle-store.ts +21 -38
  306. package/src/server/loader-registry.ts +33 -42
  307. package/src/server/request-context.ts +379 -138
  308. package/src/ssr/index.tsx +491 -182
  309. package/src/ssr/inject-rsc-eager.ts +167 -0
  310. package/src/ssr/ssr-root.tsx +228 -0
  311. package/src/static-handler.ts +10 -13
  312. package/src/testing/cache-status.ts +44 -48
  313. package/src/testing/collect-handle.ts +14 -31
  314. package/src/testing/dispatch.ts +533 -160
  315. package/src/testing/e2e/fixture.ts +45 -11
  316. package/src/testing/e2e/index.ts +1 -22
  317. package/src/testing/e2e/matchers.ts +0 -16
  318. package/src/testing/e2e/parity.ts +85 -4
  319. package/src/testing/e2e/server.ts +12 -0
  320. package/src/testing/flight-matchers.ts +7 -14
  321. package/src/testing/flight-normalize.ts +11 -0
  322. package/src/testing/flight-runtime.d.ts +36 -0
  323. package/src/testing/flight-tree.ts +682 -0
  324. package/src/testing/flight.entry.ts +30 -0
  325. package/src/testing/flight.ts +145 -70
  326. package/src/testing/generated-routes.ts +26 -50
  327. package/src/testing/index.ts +18 -19
  328. package/src/testing/internal/context.ts +184 -68
  329. package/src/testing/internal/flight-client-globals.ts +30 -0
  330. package/src/testing/internal/seed-vars.ts +54 -0
  331. package/src/testing/render-handler.ts +357 -0
  332. package/src/testing/render-route.tsx +134 -115
  333. package/src/testing/run-loader.ts +140 -51
  334. package/src/testing/run-middleware.ts +59 -33
  335. package/src/testing/run-transition-when.ts +164 -0
  336. package/src/testing/vitest-stubs/cloudflare-email.ts +1 -1
  337. package/src/testing/vitest-stubs/cloudflare-workers.ts +1 -1
  338. package/src/testing/vitest.ts +138 -16
  339. package/src/theme/ThemeProvider.tsx +56 -84
  340. package/src/theme/ThemeScript.tsx +7 -9
  341. package/src/theme/constants.ts +52 -13
  342. package/src/theme/index.ts +0 -7
  343. package/src/theme/theme-context.ts +1 -5
  344. package/src/theme/theme-script.ts +22 -21
  345. package/src/theme/use-theme.ts +0 -3
  346. package/src/types/boundaries.ts +0 -35
  347. package/src/types/cache-types.ts +13 -4
  348. package/src/types/error-types.ts +30 -90
  349. package/src/types/global-namespace.ts +15 -15
  350. package/src/types/handler-context.ts +45 -15
  351. package/src/types/index.ts +2 -10
  352. package/src/types/loader-types.ts +6 -3
  353. package/src/types/request-scope.ts +8 -22
  354. package/src/types/route-config.ts +20 -52
  355. package/src/types/route-entry.ts +0 -6
  356. package/src/types/segments.ts +100 -13
  357. package/src/urls/include-helper.ts +10 -12
  358. package/src/urls/include-provider.ts +71 -0
  359. package/src/urls/index.ts +2 -8
  360. package/src/urls/path-helper-types.ts +52 -14
  361. package/src/urls/path-helper.ts +5 -54
  362. package/src/urls/pattern-types.ts +36 -0
  363. package/src/urls/type-extraction.ts +76 -42
  364. package/src/urls/urls-function.ts +0 -14
  365. package/src/use-loader.tsx +0 -186
  366. package/src/vercel/index.ts +11 -0
  367. package/src/vercel/tracing.ts +88 -0
  368. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  369. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  370. package/src/vite/discovery/discover-routers.ts +34 -43
  371. package/src/vite/discovery/discovery-errors.ts +61 -0
  372. package/src/vite/discovery/prerender-collection.ts +33 -46
  373. package/src/vite/discovery/state.ts +12 -1
  374. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  375. package/src/vite/index.ts +9 -0
  376. package/src/vite/inject-client-debug.ts +88 -0
  377. package/src/vite/plugin-types.ts +143 -10
  378. package/src/vite/plugins/cjs-to-esm.ts +8 -12
  379. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  380. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  381. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  382. package/src/vite/plugins/expose-action-id.ts +2 -73
  383. package/src/vite/plugins/expose-id-utils.ts +85 -56
  384. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -43
  385. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -31
  386. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -20
  387. package/src/vite/plugins/expose-ids/router-transform.ts +98 -26
  388. package/src/vite/plugins/expose-internal-ids.ts +10 -1
  389. package/src/vite/plugins/performance-tracks.ts +0 -3
  390. package/src/vite/plugins/refresh-cmd.ts +1 -1
  391. package/src/vite/plugins/use-cache-transform.ts +21 -46
  392. package/src/vite/plugins/vercel-output.ts +384 -0
  393. package/src/vite/plugins/version-injector.ts +22 -27
  394. package/src/vite/plugins/version-plugin.ts +6 -66
  395. package/src/vite/plugins/virtual-entries.ts +137 -26
  396. package/src/vite/rango.ts +146 -135
  397. package/src/vite/router-discovery.ts +189 -48
  398. package/src/vite/utils/ast-handler-extract.ts +11 -20
  399. package/src/vite/utils/bundle-analysis.ts +6 -13
  400. package/src/vite/utils/client-chunks.ts +0 -6
  401. package/src/vite/utils/directive-prologue.ts +40 -0
  402. package/src/vite/utils/forward-user-plugins.ts +0 -22
  403. package/src/vite/utils/manifest-utils.ts +4 -75
  404. package/src/vite/utils/package-resolution.ts +1 -73
  405. package/src/vite/utils/prerender-utils.ts +71 -44
  406. package/src/vite/utils/shared-utils.ts +55 -37
  407. package/src/browser/react/use-client-cache.ts +0 -58
  408. package/src/browser/shallow.ts +0 -40
  409. package/src/handles/index.ts +0 -7
  410. package/src/network-error-thrower.tsx +0 -23
  411. package/src/router/middleware-cookies.ts +0 -55
@@ -29,6 +29,13 @@ 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
31
  import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
+ import { startConnectionWarmup } from "../connection-warmup.js";
33
+ import { debugLog } from "../logging.js";
34
+ import { cloneHandleData } from "../navigation-store.js";
35
+ import {
36
+ deferredHandleNames,
37
+ resolveDeferredHandleValues,
38
+ } from "../../handles/deferred-resolution.js";
32
39
 
33
40
  /**
34
41
  * Process handles from an async generator, updating the event controller
@@ -64,20 +71,117 @@ async function processHandles(
64
71
  historyKey,
65
72
  } = opts;
66
73
 
74
+ // This nav's instance token, captured before any await — processHandles runs
75
+ // right after its own commit, so this is that commit's token. generateHistoryKey
76
+ // is URL-only, so an A->B->A revisit reuses the key; the token lets a late
77
+ // resolution tell its own visit apart from a newer same-URL visit, so a stale
78
+ // nav can never clobber a fresher one's live state or cache (P1).
79
+ const myInstance = store.getNavInstance();
80
+
81
+ // True while this nav still owns the live page: same history key AND the most
82
+ // recent commit is still ours (no newer nav has committed since).
83
+ const stillLive = (): boolean =>
84
+ historyKey === store.getHistoryKey() &&
85
+ myInstance === store.getNavInstance();
86
+
67
87
  let yieldCount = 0;
68
88
  for await (const handleData of handlesGenerator) {
69
89
  // Check if user navigated away before each update.
70
90
  // This prevents handle data from cancelled navigations polluting
71
91
  // the current route's breadcrumbs (e.g., quick popstate after clicking a link).
72
92
  if (historyKey !== store.getHistoryKey()) {
73
- console.log(
93
+ debugLog(
74
94
  "[NavigationProvider] Stopping handle processing - user navigated away",
75
95
  );
76
96
  return;
77
97
  }
78
98
 
79
99
  yieldCount++;
80
- eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
100
+
101
+ // Resolve-by-default: hold the previous resolved value until this yield's
102
+ // deferred (Promise) handle values settle, then apply the fully-resolved
103
+ // snapshot. The hold needs NO extra state — we simply do not touch the store
104
+ // until the values resolve, so useHandle keeps reading (and showing) the
105
+ // previous data. A yield with no deferred value applies synchronously.
106
+ const hasDeferred = deferredHandleNames(handleData).size > 0;
107
+
108
+ if (!hasDeferred) {
109
+ eventController.setHandleData(
110
+ handleData,
111
+ matched,
112
+ isPartial,
113
+ resolvedIds,
114
+ );
115
+ // Keep the cache fresh. The token guard stops a stale same-URL nav writing
116
+ // a newer entry; the owned-write folds probe + write into one scan.
117
+ store.updateCacheHandleDataIfOwned(
118
+ historyKey,
119
+ eventController.getHandleState().data,
120
+ myInstance,
121
+ false,
122
+ );
123
+ continue;
124
+ }
125
+
126
+ // The PREVIOUS (held) snapshot — captured before the await so the cache and
127
+ // the navigate-away merge below reflect what useHandle is still showing.
128
+ const previousSnapshot = cloneHandleData(
129
+ eventController.getHandleState().data,
130
+ );
131
+
132
+ // The route HAS changed even though the handle data is held, so update
133
+ // `routeSegmentIds` (what useSegments reads) now. This leaves `data` /
134
+ // `segmentOrder` (what useHandle collects over) untouched, so useHandle keeps
135
+ // holding its previous value while useSegments reflects the new route.
136
+ eventController.setRouteSegmentIds(matched ?? []);
137
+
138
+ // Deferred-pending: the new values are not applied yet (the previous value is
139
+ // held), so the cache entry must NOT be served as fresh on a popstate return.
140
+ // Mark it STALE + handlesPending (token-guarded), storing the PREVIOUS (held)
141
+ // snapshot. P1 fix: a deferred value is a SERVER-side promise streamed via
142
+ // Flight, so a navigate-away ABORTS the stream and the resolve below never
143
+ // settles. stale makes a popstate return revalidate; handlesPending makes that
144
+ // revalidation a FULL re-render (no client segment IDs) so the server
145
+ // re-streams the handles — a diff-only revalidation would omit the unchanged
146
+ // segments' handles and the deferred value would never land (see the
147
+ // segmentIds branch in navigation-bridge.ts).
148
+ store.updateCacheHandleDataIfOwned(
149
+ historyKey,
150
+ previousSnapshot,
151
+ myInstance,
152
+ true,
153
+ true,
154
+ );
155
+
156
+ // Resolve every deferred value (allSettled; rejected + nullish dropped, sync
157
+ // values pass through). Each stream yield is a full cumulative snapshot.
158
+ const resolved = await resolveDeferredHandleValues(handleData);
159
+
160
+ if (!stillLive()) {
161
+ // Navigated away (or a same-URL nav superseded us) while resolving. We do
162
+ // NOT write `resolved` into the entry. It is THIS yield's snapshot only (on a
163
+ // partial nav, just the re-resolved segments' buckets), and a correct write
164
+ // needs setHandleData's nested per-segment merge + matched/resolvedIds
165
+ // cleanup: HandleData is handleName -> segmentId -> entries[], so a
166
+ // handle-name-level spread would drop a shared layout bucket (e.g. a
167
+ // Breadcrumbs layout crumb under L0 when the route pushed under R0) and would
168
+ // mark stale previous-route buckets fresh. We cannot run that merge here
169
+ // without touching the now-different live page. Instead leave the entry as it
170
+ // was marked before the await — stale + handlesPending — so a popstate return
171
+ // revalidates with a full re-render and re-streams the handles. A newer nav
172
+ // owning the entry has already overwritten it; nothing to do either way.
173
+ continue;
174
+ }
175
+
176
+ // Still live: apply the fully-resolved snapshot and refresh the cache fresh.
177
+ eventController.setHandleData(resolved, matched, isPartial, resolvedIds);
178
+ store.updateCacheHandleDataIfOwned(
179
+ historyKey,
180
+ eventController.getHandleState().data,
181
+ myInstance,
182
+ false,
183
+ false,
184
+ );
81
185
  }
82
186
 
83
187
  // Check again before final updates
@@ -95,8 +199,9 @@ async function processHandles(
95
199
  // After handles processing completes, update the cache's handleData.
96
200
  // This fixes a race condition where commit() caches stale handleData before
97
201
  // the async handles processing completes.
98
- // Only update if we're still on the same page (historyKey matches).
99
- if (historyKey === store.getHistoryKey()) {
202
+ // Only update if we're still on the same page AND this is still the live nav
203
+ // (the token guard stops a stale same-URL nav writing a newer nav's state).
204
+ if (stillLive()) {
100
205
  const finalHandleData = eventController.getHandleState().data;
101
206
  store.updateCacheHandleData(historyKey, finalHandleData);
102
207
  }
@@ -157,11 +262,21 @@ export interface NavigationProviderProps {
157
262
  basename?: string;
158
263
 
159
264
  /**
160
- * Live app-shell ref. When provided, the context's `basename` and `version`
161
- * properties become live getters that track app-switch updates without
162
- * invalidating the memoized context value.
265
+ * App-shell ref. When provided, the context's `basename` and `version` are
266
+ * read through it (live getters) so they don't close over a stale snapshot or
267
+ * invalidate the memoized context value. The shell is set once at init and is
268
+ * not swapped within a session — a cross-app navigation is a full document
269
+ * load (X-RSC-Reload), so the target app establishes its own shell on load.
163
270
  */
164
271
  appShellRef?: AppShellRef;
272
+
273
+ /**
274
+ * CSP nonce to expose via NonceContext. Production leaves this undefined — the
275
+ * browser has no nonce (it is a server-side HTML concern), and SSR provides the
276
+ * nonce through its own NonceContext.Provider. Test harnesses (renderRoute) set
277
+ * it to seed a nonce so components calling useNonce() can be exercised.
278
+ */
279
+ nonce?: string;
165
280
  }
166
281
 
167
282
  /**
@@ -196,6 +311,7 @@ export function NavigationProvider({
196
311
  version,
197
312
  basename,
198
313
  appShellRef,
314
+ nonce,
199
315
  }: NavigationProviderProps): ReactNode {
200
316
  // Track current payload for rendering (this triggers re-renders)
201
317
  const [payload, setPayload] = useState(initialPayload);
@@ -218,8 +334,9 @@ export function NavigationProvider({
218
334
  }, []);
219
335
 
220
336
  // basename/version are always read through a shell ref so the context value
221
- // has a single shape: a supplied appShellRef stays live (app-switch updates
222
- // it), the standalone fallback is a frozen ref over the mount-time props.
337
+ // has a single shape. Both are set once: a supplied appShellRef is seeded
338
+ // from the init payload (a cross-app navigation reloads, so it is not swapped
339
+ // in-session), and the standalone fallback wraps the mount-time props.
223
340
  const fallbackShellRef = useRef<AppShellRef | null>(null);
224
341
  if (!fallbackShellRef.current) {
225
342
  fallbackShellRef.current = createAppShellRef({ basename, version });
@@ -246,91 +363,13 @@ export function NavigationProvider({
246
363
  return value;
247
364
  }, []);
248
365
 
249
- // Connection warmup: keep TLS alive after idle periods.
250
- // After 60s of no user interaction, marks connection as "cold".
251
- // On next interaction or visibility change, sends a HEAD request to warm TLS
252
- // before the user actually clicks a link.
366
+ // Connection warmup: keep TLS alive after idle periods. After 60s of no
367
+ // interaction the connection is marked cold; the next pointer/touch
368
+ // interaction or visibility change warms TLS via a HEAD request before the
369
+ // user clicks a link. State machine lives in connection-warmup.ts.
253
370
  useEffect(() => {
254
371
  if (!warmupEnabled) return;
255
-
256
- const IDLE_TIMEOUT = 60_000;
257
- const DEBOUNCE_DELAY = 150;
258
-
259
- let idleTimer: ReturnType<typeof setTimeout> | undefined;
260
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
261
- let isCold = false;
262
- let warmupListenersAttached = false;
263
-
264
- function sendWarmup() {
265
- isCold = false;
266
- fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {});
267
- }
268
-
269
- function triggerWarmup() {
270
- if (!isCold) return;
271
- clearTimeout(debounceTimer);
272
- debounceTimer = setTimeout(() => {
273
- sendWarmup();
274
- detachWarmupListeners();
275
- resetIdleTimer();
276
- }, DEBOUNCE_DELAY);
277
- }
278
-
279
- function onVisibilityChange() {
280
- if (document.visibilityState === "visible" && isCold) {
281
- triggerWarmup();
282
- }
283
- }
284
-
285
- function attachWarmupListeners() {
286
- if (warmupListenersAttached) return;
287
- warmupListenersAttached = true;
288
- document.addEventListener("visibilitychange", onVisibilityChange);
289
- document.addEventListener("mousemove", triggerWarmup, { once: true });
290
- document.addEventListener("touchstart", triggerWarmup, { once: true });
291
- }
292
-
293
- function detachWarmupListeners() {
294
- warmupListenersAttached = false;
295
- document.removeEventListener("visibilitychange", onVisibilityChange);
296
- document.removeEventListener("mousemove", triggerWarmup);
297
- document.removeEventListener("touchstart", triggerWarmup);
298
- }
299
-
300
- function markCold() {
301
- isCold = true;
302
- attachWarmupListeners();
303
- }
304
-
305
- function resetIdleTimer() {
306
- clearTimeout(idleTimer);
307
- isCold = false;
308
- idleTimer = setTimeout(markCold, IDLE_TIMEOUT);
309
- }
310
-
311
- // Activity events that reset the idle timer
312
- const activityEvents = [
313
- "mousemove",
314
- "keydown",
315
- "touchstart",
316
- "scroll",
317
- ] as const;
318
- const activityOptions: AddEventListenerOptions = { passive: true };
319
-
320
- for (const event of activityEvents) {
321
- document.addEventListener(event, resetIdleTimer, activityOptions);
322
- }
323
-
324
- resetIdleTimer();
325
-
326
- return () => {
327
- clearTimeout(idleTimer);
328
- clearTimeout(debounceTimer);
329
- detachWarmupListeners();
330
- for (const event of activityEvents) {
331
- document.removeEventListener(event, resetIdleTimer);
332
- }
333
- };
372
+ return startConnectionWarmup();
334
373
  }, [warmupEnabled]);
335
374
 
336
375
  // Cancel non-matching prefetches when navigation starts.
@@ -426,7 +465,8 @@ export function NavigationProvider({
426
465
  payload.root instanceof Promise ? use(payload.root) : payload.root;
427
466
 
428
467
  // Wrap content in RootErrorBoundary to catch:
429
- // 1. Errors from NetworkErrorThrower (rendered during network failures)
468
+ // 1. Errors from RenderErrorThrower (network failures and unprocessable
469
+ // navigation responses, routed here by the navigation bridge)
430
470
  // 2. Client component errors that occur before/outside the segment tree's error boundary
431
471
  // 3. Errors during promise resolution or navigation state updates
432
472
  // This acts as a safety net - the segment tree has its own RootErrorBoundary that
@@ -436,10 +476,10 @@ export function NavigationProvider({
436
476
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
437
477
 
438
478
  // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
439
- // document-lifetime: its config comes from the initial load and does NOT
440
- // swap on cross-app transitions, because the ThemeProvider sits above the
441
- // segment tree and a smooth (no-reload) app switch cannot safely remount
442
- // it. A new theme config only takes effect on a full document load.
479
+ // document-lifetime: its config comes from the initial load and persists for
480
+ // the session. It sits above the segment tree and is not remounted in-session;
481
+ // a cross-app navigation is a full document load (X-RSC-Reload), so the target
482
+ // app's theme config takes effect on its own load.
443
483
  if (themeConfig) {
444
484
  content = (
445
485
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
@@ -450,9 +490,10 @@ export function NavigationProvider({
450
490
 
451
491
  // Match SSR tree shape: NonceContext.Provider is always present so
452
492
  // hydration sees the same component tree. Value is undefined on the
453
- // client — CSP nonces are a server-side HTML concern.
493
+ // client — CSP nonces are a server-side HTML concern — unless a test
494
+ // harness seeded one via the `nonce` prop.
454
495
  content = (
455
- <NonceContext.Provider value={undefined}>{content}</NonceContext.Provider>
496
+ <NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
456
497
  );
457
498
 
458
499
  return (
@@ -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: {
@@ -46,10 +46,25 @@ export function filterSegmentOrder(matched: string[]): string[] {
46
46
  const slots = slotsByParent.get(id);
47
47
  if (slots) result.push(...slots);
48
48
  }
49
- // Defensive: any slot whose parent is missing from the filtered list still
50
- // gets included rather than silently dropped. Shouldn't happen in practice.
51
49
  for (const [parent, slots] of slotsByParent) {
52
50
  if (!nonSlotSet.has(parent)) result.push(...slots);
53
51
  }
54
52
  return result;
55
53
  }
54
+
55
+ /**
56
+ * Build the "layouts and routes only" id list for useSegments().segmentIds.
57
+ *
58
+ * Strips parallel slot ids (contain ".@") and loader sub-ids ("D" followed by
59
+ * a digit, e.g. "M0L0D1.user") without reordering. Distinct from
60
+ * filterSegmentOrder, which also reorders slots after their parent for handle
61
+ * collection.
62
+ *
63
+ * Shared by SSR (ssr/index.tsx) and the client event controller
64
+ * (event-controller.ts) so both produce identical output; if they diverge,
65
+ * useSegments().segmentIds rendered during SSR and after hydration disagree
66
+ * and React reports a hydration mismatch.
67
+ */
68
+ export function filterRouteSegmentIds(matched: string[]): string[] {
69
+ return matched.filter((id) => !id.includes(".@") && !/D\d+\./.test(id));
70
+ }
@@ -1,55 +1,4 @@
1
- // React exports for browser navigation
2
-
3
- // Hook with Zustand-style selectors
4
- export { useNavigation } from "./use-navigation.js";
5
-
6
- // Router actions hook (stable reference, no re-renders)
7
- export { useRouter } from "./use-router.js";
8
-
9
- // URL hooks
10
- export { usePathname } from "./use-pathname.js";
11
- export { useSearchParams } from "./use-search-params.js";
12
- export { useParams } from "./use-params.js";
13
-
14
- // Action state tracking hook
15
- export { useAction, type TrackedActionState } from "./use-action.js";
16
-
17
- // Segments state hook
18
- export { useSegments, type SegmentsState } from "./use-segments.js";
19
-
20
- // Handle data hook
21
- export { useHandle } from "./use-handle.js";
22
-
23
- // Mount-aware reverse hook
24
- export { useReverse } from "./use-reverse.js";
25
-
26
- // Client cache controls hook
27
- export {
28
- useClientCache,
29
- type ClientCacheControls,
30
- } from "./use-client-cache.js";
31
-
32
- // Provider
33
1
  export {
34
2
  NavigationProvider,
35
3
  type NavigationProviderProps,
36
4
  } from "./NavigationProvider.js";
37
-
38
- // Context (for advanced usage)
39
- export {
40
- NavigationStoreContext,
41
- type NavigationStoreContextValue,
42
- } from "./context.js";
43
-
44
- // Link component
45
- export { Link, type LinkProps, type PrefetchStrategy } from "./Link.js";
46
-
47
- // Link status hook
48
- export { useLinkStatus, type LinkStatus } from "./use-link-status.js";
49
-
50
- // Scroll restoration
51
- export {
52
- ScrollRestoration,
53
- useScrollRestoration,
54
- type ScrollRestorationProps,
55
- } from "./ScrollRestoration.js";
@@ -1,8 +1,3 @@
1
- /**
2
- * Shared location state utilities - works in both RSC and client contexts
3
- * No "use client" directive so it can be imported from RSC
4
- */
5
-
6
1
  import type { ReactElement } from "react";
7
2
 
8
3
  /**
@@ -26,16 +21,8 @@ export interface LocationStateOptions {
26
21
 
27
22
  type LocationStateUnsafeFn = (...args: never[]) => unknown;
28
23
 
29
- // Broadest constructor signature (`abstract` covers both abstract and concrete
30
- // classes). A class passed as state has a `new` signature, not a call signature,
31
- // so it slips past LocationStateUnsafeFn; at runtime the lazy-getter path
32
- // (`typeof value === "function"`) then mistakes it for a getter and throws.
33
24
  type LocationStateUnsafeCtor = abstract new (...args: never[]) => unknown;
34
25
 
35
- // `unknown` cannot be verified serializable, so it is rejected (callers must
36
- // supply a concrete type). `any` deliberately defeats type checking and is NOT
37
- // guardable — it is assignable to the branded error too, so the check always
38
- // passes; it remains an explicit escape hatch.
39
26
  type IsAny<T> = 0 extends 1 & T ? true : false;
40
27
  type IsUnknown<T> =
41
28
  IsAny<T> extends true ? false : unknown extends T ? true : false;
@@ -268,7 +255,14 @@ export function createLocationState<TState>(
268
255
  );
269
256
  }
270
257
  const key = getKey();
271
- const current = window.history.state ?? {};
258
+ // history.state may be a non-null primitive (string/number/boolean) if
259
+ // non-Rango code called pushState/replaceState with one. `?? {}` only
260
+ // catches null/undefined, so spreading a primitive would yield indexed
261
+ // char/no keys and corrupt history.state. Coerce any non-object to a fresh
262
+ // dict — mirrors the delete() guard.
263
+ const existing = window.history.state;
264
+ const current =
265
+ existing !== null && typeof existing === "object" ? existing : {};
272
266
  window.history.replaceState(
273
267
  { ...current, [key]: value },
274
268
  "",
@@ -288,7 +282,12 @@ export function createLocationState<TState>(
288
282
  }
289
283
  const key = getKey();
290
284
  const current = window.history.state;
291
- if (current == null || !(key in current)) return;
285
+ // history.state may be a non-null primitive (string/number/boolean) if
286
+ // non-Rango code called pushState/replaceState with one. `key in
287
+ // <primitive>` throws, so require an object before the `in` check; a
288
+ // primitive carries no slots, so deletion is a no-op.
289
+ if (current === null || typeof current !== "object" || !(key in current))
290
+ return;
292
291
  const next = { ...current };
293
292
  delete next[key];
294
293
  window.history.replaceState(next, "", window.location.href);
@@ -3,7 +3,6 @@
3
3
  import { useState, useEffect, useRef } from "react";
4
4
  import type { LocationStateDefinition } from "./location-state-shared.js";
5
5
 
6
- // Re-export shared utilities and types
7
6
  export {
8
7
  createLocationState,
9
8
  isLocationStateEntry,
@@ -24,32 +24,24 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
24
24
  result: null,
25
25
  };
26
26
 
27
- /**
28
- * Normalize action ID - returns the ID as-is
29
- *
30
- * Server actions have IDs like "hash#actionName" or "src/actions.ts#actionName".
31
- * When using function references, we use the full ID for exact matching.
32
- * When using strings, the event controller supports suffix matching
33
- * (e.g., "addToCart" matches "hash#addToCart").
34
- */
35
- function normalizeActionId(actionId: string): string {
36
- return actionId;
37
- }
38
-
39
27
  /**
40
28
  * Extract action ID from a server action function or string.
41
29
  *
42
30
  * Actions passed as props from server components lose their metadata
43
31
  * during RSC serialization - use a string action name instead.
32
+ *
33
+ * The extracted $$id (e.g. "hash#actionName" or "src/actions.ts#actionName")
34
+ * is returned as-is. Suffix-vs-exact matching against this ID happens
35
+ * downstream in the event controller, not here.
44
36
  */
45
- export function getActionId(action: ServerActionFunction | string): string {
37
+ function getActionId(action: ServerActionFunction | string): string {
46
38
  invariant(
47
39
  typeof action === "function" || typeof action === "string",
48
40
  `useAction: action must be a function or string, got ${typeof action}`,
49
41
  );
50
42
  const actionId = (action as any)?.$$id;
51
43
  if (actionId) {
52
- return normalizeActionId(actionId);
44
+ return actionId;
53
45
  }
54
46
 
55
47
  // If action is a string, use it directly
@@ -162,7 +154,6 @@ export function useAction<T>(
162
154
  });
163
155
  const prevSelected = useRef(baseState);
164
156
  prevSelected.current = baseState;
165
- // useOptimistic allows immediate updates during transitions/actions
166
157
  const [optimisticState, setOptimisticState] = useOptimistic<
167
158
  T | TrackedActionState
168
159
  >(null!);
@@ -43,7 +43,6 @@ export function useHandle<T, A, S>(
43
43
  ): Rango.FlightSerialize<A> | S {
44
44
  const ctx = useContext(NavigationStoreContext);
45
45
 
46
- // Initial state from context event controller, or empty fallback without provider.
47
46
  const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
48
47
  if (!ctx) {
49
48
  const collected = collectHandleData(
@@ -54,7 +53,6 @@ export function useHandle<T, A, S>(
54
53
  return selector ? selector(collected) : collected;
55
54
  }
56
55
 
57
- // On client, use event controller state
58
56
  const state = ctx.eventController.getHandleState();
59
57
  const collected = collectHandleData(
60
58
  handle,
@@ -65,15 +63,12 @@ export function useHandle<T, A, S>(
65
63
  });
66
64
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
67
65
 
68
- // Track previous value for shallow comparison
69
66
  const prevValueRef = useRef(value);
70
67
  prevValueRef.current = value;
71
68
 
72
- // Ref keeps the latest selector without re-subscribing on every render.
73
69
  const selectorRef = useRef(selector);
74
70
  selectorRef.current = selector;
75
71
 
76
- // Subscribe to handle data changes (client only)
77
72
  useEffect(() => {
78
73
  if (!ctx) return;
79
74
 
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useCallback } from "react";
3
4
  import { href, type ValidPaths } from "../../href-client.js";
4
5
  import { useMount } from "./use-mount.js";
5
6
 
@@ -36,5 +37,11 @@ import { useMount } from "./use-mount.js";
36
37
  */
37
38
  export function useHref(): (path: `/${string}`) => string {
38
39
  const mount = useMount();
39
- return (path: `/${string}`) => href(path as ValidPaths, mount);
40
+ // Memoize on `mount` (stable within a route) so the returned function is
41
+ // referentially stable across re-renders — a consumer can safely pass it as a
42
+ // dependency or a prop to a memoized child without forcing re-renders.
43
+ return useCallback(
44
+ (path: `/${string}`) => href(path as ValidPaths, mount),
45
+ [mount],
46
+ );
40
47
  }