@rangojs/router 0.0.0-experimental.eb0645d3 → 0.0.0-experimental.f1468e3c

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 (392) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +126 -16
  3. package/dist/bin/rango.js +319 -95
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +2724 -1053
  6. package/package.json +68 -14
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +64 -2
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +224 -32
  11. package/skills/caching/SKILL.md +279 -17
  12. package/skills/composability/SKILL.md +27 -3
  13. package/skills/css/SKILL.md +76 -0
  14. package/skills/debug-manifest/SKILL.md +4 -2
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +11 -9
  17. package/skills/hooks/SKILL.md +243 -29
  18. package/skills/host-router/SKILL.md +83 -23
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +68 -19
  21. package/skills/layout/SKILL.md +13 -9
  22. package/skills/links/SKILL.md +190 -23
  23. package/skills/loader/SKILL.md +235 -9
  24. package/skills/middleware/SKILL.md +18 -10
  25. package/skills/migrate-nextjs/SKILL.md +43 -19
  26. package/skills/migrate-react-router/SKILL.md +8 -2
  27. package/skills/mime-routes/SKILL.md +28 -1
  28. package/skills/observability/SKILL.md +172 -0
  29. package/skills/parallel/SKILL.md +18 -7
  30. package/skills/prerender/SKILL.md +65 -60
  31. package/skills/rango/SKILL.md +251 -24
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +115 -48
  34. package/skills/route/SKILL.md +46 -5
  35. package/skills/router-setup/SKILL.md +30 -8
  36. package/skills/scripts/SKILL.md +179 -0
  37. package/skills/server-actions/SKILL.md +775 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +130 -0
  40. package/skills/testing/bindings.md +103 -0
  41. package/skills/testing/cache-prerender.md +127 -0
  42. package/skills/testing/client-components.md +124 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +91 -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 +122 -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 +123 -0
  54. package/skills/typesafety/SKILL.md +322 -29
  55. package/skills/use-cache/SKILL.md +57 -14
  56. package/skills/view-transitions/SKILL.md +337 -0
  57. package/src/__augment-tests__/augment.ts +81 -0
  58. package/src/__augment-tests__/augmented.check.ts +116 -0
  59. package/src/__internal.ts +0 -65
  60. package/src/browser/action-coordinator.ts +53 -36
  61. package/src/browser/action-fence.ts +47 -0
  62. package/src/browser/app-shell.ts +39 -0
  63. package/src/browser/connection-warmup.ts +134 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +192 -150
  66. package/src/browser/history-state.ts +21 -0
  67. package/src/browser/index.ts +3 -3
  68. package/src/browser/invalidate-client-cache.ts +52 -0
  69. package/src/browser/navigation-bridge.ts +94 -25
  70. package/src/browser/navigation-client.ts +121 -84
  71. package/src/browser/navigation-store-handle.ts +38 -0
  72. package/src/browser/navigation-store.ts +115 -67
  73. package/src/browser/navigation-transaction.ts +9 -59
  74. package/src/browser/network-error-handler.ts +34 -7
  75. package/src/browser/partial-update.ts +147 -128
  76. package/src/browser/prefetch/cache.ts +107 -56
  77. package/src/browser/prefetch/fetch.ts +204 -34
  78. package/src/browser/prefetch/queue.ts +6 -3
  79. package/src/browser/rango-state.ts +158 -76
  80. package/src/browser/react/Link.tsx +30 -7
  81. package/src/browser/react/NavigationProvider.tsx +283 -118
  82. package/src/browser/react/ScrollRestoration.tsx +10 -6
  83. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  84. package/src/browser/react/filter-segment-order.ts +66 -7
  85. package/src/browser/react/index.ts +0 -48
  86. package/src/browser/react/location-state-shared.ts +178 -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 +17 -14
  90. package/src/browser/react/use-href.tsx +8 -1
  91. package/src/browser/react/use-link-status.ts +33 -8
  92. package/src/browser/react/use-navigation.ts +10 -5
  93. package/src/browser/react/use-params.ts +11 -11
  94. package/src/browser/react/use-reverse.ts +106 -0
  95. package/src/browser/react/use-router.ts +25 -3
  96. package/src/browser/react/use-search-params.ts +0 -5
  97. package/src/browser/react/use-segments.ts +11 -21
  98. package/src/browser/response-adapter.ts +99 -8
  99. package/src/browser/rsc-router.tsx +91 -24
  100. package/src/browser/scroll-restoration.ts +30 -17
  101. package/src/browser/segment-structure-assert.ts +2 -2
  102. package/src/browser/server-action-bridge.ts +214 -55
  103. package/src/browser/types.ts +80 -9
  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 +60 -35
  107. package/src/build/generate-route-types.ts +2 -1
  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 +117 -14
  111. package/src/build/route-types/ast-route-extraction.ts +15 -8
  112. package/src/build/route-types/codegen.ts +16 -5
  113. package/src/build/route-types/include-resolution.ts +117 -23
  114. package/src/build/route-types/param-extraction.ts +6 -3
  115. package/src/build/route-types/per-module-writer.ts +22 -6
  116. package/src/build/route-types/router-processing.ts +55 -28
  117. package/src/build/route-types/scan-filter.ts +1 -1
  118. package/src/build/route-types/source-scan.ts +216 -0
  119. package/src/build/runtime-discovery.ts +9 -20
  120. package/src/cache/cache-error.ts +104 -0
  121. package/src/cache/cache-key-utils.ts +29 -13
  122. package/src/cache/cache-policy.ts +108 -34
  123. package/src/cache/cache-runtime.ts +224 -41
  124. package/src/cache/cache-scope.ts +188 -82
  125. package/src/cache/cache-tag.ts +103 -0
  126. package/src/cache/cf/cf-base64.ts +33 -0
  127. package/src/cache/cf/cf-cache-constants.ts +127 -0
  128. package/src/cache/cf/cf-cache-store.ts +1989 -378
  129. package/src/cache/cf/cf-cache-types.ts +349 -0
  130. package/src/cache/cf/cf-kv-utils.ts +46 -0
  131. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  132. package/src/cache/cf/index.ts +6 -16
  133. package/src/cache/document-cache.ts +89 -21
  134. package/src/cache/handle-snapshot.ts +70 -0
  135. package/src/cache/index.ts +10 -20
  136. package/src/cache/memory-segment-store.ts +136 -37
  137. package/src/cache/profile-registry.ts +46 -31
  138. package/src/cache/read-through-swr.ts +56 -12
  139. package/src/cache/segment-codec.ts +9 -17
  140. package/src/cache/tag-invalidation.ts +230 -0
  141. package/src/cache/types.ts +37 -100
  142. package/src/client.rsc.tsx +44 -21
  143. package/src/client.tsx +36 -61
  144. package/src/cloudflare/index.ts +11 -0
  145. package/src/cloudflare/tracing.ts +109 -0
  146. package/src/component-utils.ts +19 -0
  147. package/src/components/DefaultDocument.tsx +8 -2
  148. package/src/context-var.ts +18 -6
  149. package/src/decode-loader-results.ts +52 -0
  150. package/src/defer.ts +196 -0
  151. package/src/deps/ssr.ts +0 -1
  152. package/src/encode-kv.ts +49 -0
  153. package/src/errors.ts +30 -4
  154. package/src/escape-script.ts +52 -0
  155. package/src/handle.ts +31 -23
  156. package/src/handles/MetaTags.tsx +62 -19
  157. package/src/handles/Scripts.tsx +183 -0
  158. package/src/handles/breadcrumbs.ts +37 -8
  159. package/src/handles/is-thenable.ts +19 -0
  160. package/src/handles/meta.ts +51 -40
  161. package/src/handles/script.ts +244 -0
  162. package/src/host/cookie-handler.ts +9 -60
  163. package/src/host/errors.ts +0 -24
  164. package/src/host/index.ts +8 -2
  165. package/src/host/pattern-matcher.ts +23 -52
  166. package/src/host/router.ts +107 -99
  167. package/src/host/testing.ts +40 -27
  168. package/src/host/types.ts +37 -4
  169. package/src/host/utils.ts +1 -1
  170. package/src/href-client.ts +137 -22
  171. package/src/index.rsc.ts +96 -12
  172. package/src/index.ts +94 -14
  173. package/src/internal-debug.ts +11 -10
  174. package/src/loader-store.ts +500 -0
  175. package/src/loader.rsc.ts +20 -13
  176. package/src/loader.ts +12 -11
  177. package/src/missing-id-error.ts +68 -0
  178. package/src/outlet-context.ts +1 -1
  179. package/src/outlet-provider.tsx +1 -5
  180. package/src/prerender/param-hash.ts +16 -16
  181. package/src/prerender/store.ts +32 -37
  182. package/src/prerender.ts +61 -6
  183. package/src/redirect-origin.ts +100 -0
  184. package/src/regex-escape.ts +8 -0
  185. package/src/render-error-thrower.tsx +20 -0
  186. package/src/response-utils.ts +34 -0
  187. package/src/reverse.ts +65 -40
  188. package/src/root-error-boundary.tsx +1 -19
  189. package/src/route-content-wrapper.tsx +19 -77
  190. package/src/route-definition/dsl-helpers.ts +304 -309
  191. package/src/route-definition/helper-factories.ts +28 -140
  192. package/src/route-definition/helpers-types.ts +82 -55
  193. package/src/route-definition/index.ts +1 -2
  194. package/src/route-definition/redirect.ts +44 -11
  195. package/src/route-definition/resolve-handler-use.ts +12 -1
  196. package/src/route-definition/use-item-types.ts +29 -0
  197. package/src/route-map-builder.ts +0 -16
  198. package/src/route-types.ts +19 -46
  199. package/src/router/basename.ts +14 -0
  200. package/src/router/content-negotiation.ts +73 -25
  201. package/src/router/error-handling.ts +45 -18
  202. package/src/router/find-match.ts +44 -23
  203. package/src/router/handler-context.ts +27 -43
  204. package/src/router/instrument.ts +350 -0
  205. package/src/router/intercept-resolution.ts +39 -20
  206. package/src/router/lazy-includes.ts +10 -47
  207. package/src/router/loader-resolution.ts +155 -72
  208. package/src/router/logging.ts +0 -6
  209. package/src/router/manifest.ts +18 -29
  210. package/src/router/match-api.ts +9 -24
  211. package/src/router/match-context.ts +0 -22
  212. package/src/router/match-handlers.ts +58 -58
  213. package/src/router/match-middleware/background-revalidation.ts +40 -24
  214. package/src/router/match-middleware/cache-lookup.ts +159 -285
  215. package/src/router/match-middleware/cache-store.ts +64 -52
  216. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  217. package/src/router/match-middleware/segment-resolution.ts +0 -22
  218. package/src/router/match-pipelines.ts +1 -42
  219. package/src/router/match-result.ts +44 -74
  220. package/src/router/metrics.ts +0 -34
  221. package/src/router/middleware-types.ts +7 -134
  222. package/src/router/middleware.ts +247 -166
  223. package/src/router/navigation-snapshot.ts +0 -51
  224. package/src/router/params-util.ts +23 -0
  225. package/src/router/pattern-matching.ts +85 -94
  226. package/src/router/prefetch-cache-ttl.ts +51 -0
  227. package/src/router/prerender-match.ts +104 -65
  228. package/src/router/preview-match.ts +3 -1
  229. package/src/router/request-classification.ts +28 -62
  230. package/src/router/revalidation.ts +123 -73
  231. package/src/router/route-snapshot.ts +0 -1
  232. package/src/router/router-context.ts +3 -28
  233. package/src/router/router-interfaces.ts +83 -35
  234. package/src/router/router-options.ts +136 -5
  235. package/src/router/router-registry.ts +2 -5
  236. package/src/router/segment-resolution/fresh.ts +97 -84
  237. package/src/router/segment-resolution/helpers.ts +86 -6
  238. package/src/router/segment-resolution/loader-cache.ts +76 -39
  239. package/src/router/segment-resolution/revalidation.ts +272 -320
  240. package/src/router/segment-resolution/static-store.ts +19 -5
  241. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  242. package/src/router/segment-resolution/view-transition-default.ts +56 -0
  243. package/src/router/segment-resolution.ts +5 -1
  244. package/src/router/segment-wrappers.ts +6 -5
  245. package/src/router/state-cookie-name.ts +33 -0
  246. package/src/router/substitute-pattern-params.ts +56 -0
  247. package/src/router/telemetry-otel.ts +161 -199
  248. package/src/router/telemetry.ts +96 -19
  249. package/src/router/timeout.ts +0 -20
  250. package/src/router/tracing.ts +206 -0
  251. package/src/router/trie-matching.ts +162 -64
  252. package/src/router/types.ts +9 -63
  253. package/src/router/url-params.ts +0 -5
  254. package/src/router.ts +110 -55
  255. package/src/rsc/handler-context.ts +3 -2
  256. package/src/rsc/handler.ts +264 -220
  257. package/src/rsc/helpers.ts +100 -6
  258. package/src/rsc/index.ts +2 -5
  259. package/src/rsc/json-route-result.ts +38 -0
  260. package/src/rsc/loader-fetch.ts +114 -38
  261. package/src/rsc/manifest-init.ts +28 -41
  262. package/src/rsc/origin-guard.ts +39 -25
  263. package/src/rsc/progressive-enhancement.ts +117 -11
  264. package/src/rsc/redirect-guard.ts +99 -0
  265. package/src/rsc/response-cache-serve.ts +238 -0
  266. package/src/rsc/response-error.ts +79 -12
  267. package/src/rsc/response-route-handler.ts +88 -188
  268. package/src/rsc/rsc-rendering.ts +98 -76
  269. package/src/rsc/runtime-warnings.ts +23 -10
  270. package/src/rsc/server-action.ts +281 -117
  271. package/src/rsc/ssr-setup.ts +16 -0
  272. package/src/rsc/transition-gate.ts +89 -0
  273. package/src/rsc/types.ts +23 -5
  274. package/src/runtime-env.ts +18 -0
  275. package/src/search-params.ts +35 -30
  276. package/src/segment-loader-promise.ts +31 -4
  277. package/src/segment-system.tsx +254 -143
  278. package/src/serialize.ts +243 -0
  279. package/src/server/context.ts +163 -51
  280. package/src/server/cookie-parse.ts +32 -0
  281. package/src/server/cookie-store.ts +80 -5
  282. package/src/server/handle-store.ts +21 -38
  283. package/src/server/loader-registry.ts +33 -42
  284. package/src/server/request-context.ts +287 -178
  285. package/src/ssr/index.tsx +21 -16
  286. package/src/static-handler.ts +10 -13
  287. package/src/testing/cache-status.ts +162 -0
  288. package/src/testing/collect-handle.ts +40 -0
  289. package/src/testing/dispatch.ts +701 -0
  290. package/src/testing/dom.entry.ts +22 -0
  291. package/src/testing/e2e/fixture.ts +188 -0
  292. package/src/testing/e2e/index.ts +128 -0
  293. package/src/testing/e2e/matchers.ts +35 -0
  294. package/src/testing/e2e/page-helpers.ts +272 -0
  295. package/src/testing/e2e/parity.ts +387 -0
  296. package/src/testing/e2e/server.ts +195 -0
  297. package/src/testing/flight-matchers.ts +97 -0
  298. package/src/testing/flight-normalize.ts +11 -0
  299. package/src/testing/flight-runtime.d.ts +57 -0
  300. package/src/testing/flight-tree.ts +682 -0
  301. package/src/testing/flight.entry.ts +52 -0
  302. package/src/testing/flight.ts +257 -0
  303. package/src/testing/generated-routes.ts +183 -0
  304. package/src/testing/index.ts +105 -0
  305. package/src/testing/internal/context.ts +371 -0
  306. package/src/testing/internal/flight-client-globals.ts +30 -0
  307. package/src/testing/internal/seed-vars.ts +54 -0
  308. package/src/testing/render-handler.ts +357 -0
  309. package/src/testing/render-route.tsx +581 -0
  310. package/src/testing/run-loader.ts +385 -0
  311. package/src/testing/run-middleware.ts +205 -0
  312. package/src/testing/run-transition-when.ts +164 -0
  313. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  314. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  315. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  316. package/src/testing/vitest-stubs/version.ts +5 -0
  317. package/src/testing/vitest.ts +305 -0
  318. package/src/theme/ThemeProvider.tsx +20 -58
  319. package/src/theme/ThemeScript.tsx +7 -9
  320. package/src/theme/constants.ts +52 -13
  321. package/src/theme/index.ts +0 -7
  322. package/src/theme/theme-context.ts +1 -5
  323. package/src/theme/theme-script.ts +22 -21
  324. package/src/theme/use-theme.ts +0 -3
  325. package/src/types/boundaries.ts +0 -35
  326. package/src/types/cache-types.ts +13 -4
  327. package/src/types/error-types.ts +30 -90
  328. package/src/types/global-namespace.ts +54 -41
  329. package/src/types/handler-context.ts +110 -62
  330. package/src/types/index.ts +3 -10
  331. package/src/types/loader-types.ts +11 -9
  332. package/src/types/request-scope.ts +112 -0
  333. package/src/types/route-config.ts +6 -50
  334. package/src/types/route-entry.ts +0 -6
  335. package/src/types/segments.ts +135 -14
  336. package/src/urls/include-helper.ts +9 -56
  337. package/src/urls/index.ts +1 -11
  338. package/src/urls/path-helper-types.ts +29 -12
  339. package/src/urls/path-helper.ts +17 -106
  340. package/src/urls/pattern-types.ts +36 -19
  341. package/src/urls/response-types.ts +22 -29
  342. package/src/urls/type-extraction.ts +58 -139
  343. package/src/urls/urls-function.ts +1 -19
  344. package/src/use-loader.tsx +292 -107
  345. package/src/vite/debug.ts +185 -0
  346. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  347. package/src/vite/discovery/discover-routers.ts +126 -85
  348. package/src/vite/discovery/discovery-errors.ts +194 -0
  349. package/src/vite/discovery/gate-state.ts +171 -0
  350. package/src/vite/discovery/prerender-collection.ts +96 -68
  351. package/src/vite/discovery/route-types-writer.ts +40 -84
  352. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  353. package/src/vite/discovery/state.ts +44 -0
  354. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  355. package/src/vite/index.ts +2 -0
  356. package/src/vite/inject-client-debug.ts +36 -0
  357. package/src/vite/plugin-types.ts +126 -8
  358. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  359. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  360. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  361. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  362. package/src/vite/plugins/expose-action-id.ts +48 -95
  363. package/src/vite/plugins/expose-id-utils.ts +88 -55
  364. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  365. package/src/vite/plugins/expose-ids/handler-transform.ts +11 -90
  366. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  367. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  368. package/src/vite/plugins/expose-internal-ids.ts +505 -486
  369. package/src/vite/plugins/performance-tracks.ts +26 -25
  370. package/src/vite/plugins/refresh-cmd.ts +1 -1
  371. package/src/vite/plugins/use-cache-transform.ts +73 -83
  372. package/src/vite/plugins/version-injector.ts +40 -29
  373. package/src/vite/plugins/version-plugin.ts +37 -40
  374. package/src/vite/plugins/virtual-entries.ts +39 -25
  375. package/src/vite/rango.ts +109 -118
  376. package/src/vite/router-discovery.ts +718 -119
  377. package/src/vite/utils/ast-handler-extract.ts +26 -35
  378. package/src/vite/utils/banner.ts +1 -1
  379. package/src/vite/utils/bundle-analysis.ts +10 -15
  380. package/src/vite/utils/client-chunks.ts +184 -0
  381. package/src/vite/utils/directive-prologue.ts +40 -0
  382. package/src/vite/utils/forward-user-plugins.ts +171 -0
  383. package/src/vite/utils/manifest-utils.ts +4 -59
  384. package/src/vite/utils/package-resolution.ts +20 -52
  385. package/src/vite/utils/prerender-utils.ts +54 -39
  386. package/src/vite/utils/shared-utils.ts +90 -41
  387. package/src/browser/action-response-classifier.ts +0 -99
  388. package/src/browser/react/use-client-cache.ts +0 -58
  389. package/src/browser/shallow.ts +0 -40
  390. package/src/handles/index.ts +0 -7
  391. package/src/network-error-thrower.tsx +0 -23
  392. package/src/router/middleware-cookies.ts +0 -55
@@ -1,11 +1,70 @@
1
1
  /**
2
- * Filter segment IDs to only include routes and layouts.
3
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
2
+ * Build the handle-collection segment order from a raw `matched` list.
3
+ *
4
+ * Two responsibilities:
5
+ *
6
+ * 1. Drop loader sub-ids ("D" followed by a digit, e.g. "M0L0D1.user") —
7
+ * loaders never push handles.
8
+ *
9
+ * 2. Place each parallel slot id (contains ".@") immediately after its
10
+ * parent layout/route id. Raw segment-resolution emission order does NOT
11
+ * guarantee this: route-mounted parallels are resolved/pushed BEFORE the
12
+ * route handler's segment is appended (see fresh.ts:resolveSegment for
13
+ * routes, and revalidation.ts ~915-919), so matched can read
14
+ * `[..., R0.@panel, R0]`. collectHandleData consumes segmentOrder verbatim
15
+ * with later-wins semantics, so without normalization the route handler's
16
+ * Meta would override the slot's more-specific Meta — backwards.
17
+ *
18
+ * Slot-id format is `<parentShortCode>.@<slotName>`; `parentShortCode` never
19
+ * contains ".@", so splitting at the first ".@" reliably yields the parent.
4
20
  */
5
21
  export function filterSegmentOrder(matched: string[]): string[] {
6
- return matched.filter((id) => {
7
- if (id.includes(".@")) return false;
8
- if (/D\d+\./.test(id)) return false;
9
- return true;
10
- });
22
+ const slotsByParent = new Map<string, string[]>();
23
+ const nonSlots: string[] = [];
24
+ const nonSlotSet = new Set<string>();
25
+
26
+ for (const id of matched) {
27
+ if (/D\d+\./.test(id)) continue;
28
+ const slotIdx = id.indexOf(".@");
29
+ if (slotIdx >= 0) {
30
+ const parent = id.slice(0, slotIdx);
31
+ const list = slotsByParent.get(parent);
32
+ if (list) {
33
+ list.push(id);
34
+ } else {
35
+ slotsByParent.set(parent, [id]);
36
+ }
37
+ } else {
38
+ nonSlots.push(id);
39
+ nonSlotSet.add(id);
40
+ }
41
+ }
42
+
43
+ const result: string[] = [];
44
+ for (const id of nonSlots) {
45
+ result.push(id);
46
+ const slots = slotsByParent.get(id);
47
+ if (slots) result.push(...slots);
48
+ }
49
+ for (const [parent, slots] of slotsByParent) {
50
+ if (!nonSlotSet.has(parent)) result.push(...slots);
51
+ }
52
+ return result;
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));
11
70
  }
@@ -1,52 +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
- // Client cache controls hook
24
- export {
25
- useClientCache,
26
- type ClientCacheControls,
27
- } from "./use-client-cache.js";
28
-
29
- // Provider
30
1
  export {
31
2
  NavigationProvider,
32
3
  type NavigationProviderProps,
33
4
  } from "./NavigationProvider.js";
34
-
35
- // Context (for advanced usage)
36
- export {
37
- NavigationStoreContext,
38
- type NavigationStoreContextValue,
39
- } from "./context.js";
40
-
41
- // Link component
42
- export { Link, type LinkProps, type PrefetchStrategy } from "./Link.js";
43
-
44
- // Link status hook
45
- export { useLinkStatus, type LinkStatus } from "./use-link-status.js";
46
-
47
- // Scroll restoration
48
- export {
49
- ScrollRestoration,
50
- useScrollRestoration,
51
- type ScrollRestorationProps,
52
- } from "./ScrollRestoration.js";
@@ -1,7 +1,4 @@
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
- */
1
+ import type { ReactElement } from "react";
5
2
 
6
3
  /**
7
4
  * Internal entry representing a state value with its unique key.
@@ -22,6 +19,80 @@ export interface LocationStateOptions {
22
19
  flash?: boolean;
23
20
  }
24
21
 
22
+ type LocationStateUnsafeFn = (...args: never[]) => unknown;
23
+
24
+ type LocationStateUnsafeCtor = abstract new (...args: never[]) => unknown;
25
+
26
+ type IsAny<T> = 0 extends 1 & T ? true : false;
27
+ type IsUnknown<T> =
28
+ IsAny<T> extends true ? false : unknown extends T ? true : false;
29
+
30
+ /**
31
+ * Branded error surfaced when a value that cannot live in location state is
32
+ * used. Location state is written into `history.state`, which uses the
33
+ * structured clone algorithm; React elements, functions, and symbols throw a
34
+ * `DataCloneError` at runtime. Carries a human-readable reason so the compile
35
+ * error explains the fix.
36
+ */
37
+ export type LocationStateUnsafe<Reason extends string> = {
38
+ readonly __rango_location_state_unsafe: Reason;
39
+ };
40
+
41
+ /**
42
+ * Maps `T` to itself when it is safe to store in location state, or to a branded
43
+ * {@link LocationStateUnsafe} error for the disallowed parts: `unknown`, React
44
+ * elements (RSC/JSX content), functions, class constructors, and symbols.
45
+ * Recurses through arrays, `Map`, `Set`, and plain objects; structured-clone
46
+ * built-ins (`Date`, `RegExp`, typed arrays, `Blob`, `File`, `FormData`) pass
47
+ * through. Consumed by {@link ValidateLocationState}, which is intersected into a
48
+ * definition's value parameter so posting RSC content is a COMPILE error, not a
49
+ * runtime `DataCloneError`. (`any` is unguardable and remains an escape hatch.)
50
+ */
51
+ export type LocationStateSafe<T> =
52
+ IsUnknown<T> extends true
53
+ ? LocationStateUnsafe<"location state needs an explicit, concrete type; `unknown` cannot be verified as serializable">
54
+ : T extends LocationStateUnsafeFn
55
+ ? LocationStateUnsafe<"functions cannot be stored in location state">
56
+ : T extends LocationStateUnsafeCtor
57
+ ? LocationStateUnsafe<"class constructors cannot be stored in location state">
58
+ : T extends symbol
59
+ ? LocationStateUnsafe<"symbols cannot be stored in location state">
60
+ : T extends ReactElement
61
+ ? LocationStateUnsafe<"React/RSC content cannot be stored in location state; store plain data and render it on arrival">
62
+ : T extends string | number | boolean | bigint | null | undefined
63
+ ? T
64
+ : T extends
65
+ | Date
66
+ | RegExp
67
+ | ArrayBuffer
68
+ | ArrayBufferView
69
+ | Blob
70
+ | File
71
+ | FormData
72
+ ? T
73
+ : T extends ReadonlyMap<infer K, infer V>
74
+ ? ReadonlyMap<LocationStateSafe<K>, LocationStateSafe<V>>
75
+ : T extends ReadonlySet<infer V>
76
+ ? ReadonlySet<LocationStateSafe<V>>
77
+ : T extends readonly unknown[]
78
+ ? { [K in keyof T]: LocationStateSafe<T[K]> }
79
+ : T extends object
80
+ ? { [K in keyof T]: LocationStateSafe<T[K]> }
81
+ : T;
82
+
83
+ /**
84
+ * `unknown` (a no-op) when `T` is safe to store in location state, otherwise a
85
+ * branded {@link LocationStateUnsafe} object. Intersected into the value
86
+ * parameter of a definition's call and `write()` so POSTING RSC content (or any
87
+ * non-serializable value) is a compile error whose text carries the reason —
88
+ * without a `TState extends ...` self-constraint, which TypeScript rejects as
89
+ * circular (TS2313). For safe `T`, `value & unknown` collapses back to `value`,
90
+ * so valid usage is unchanged.
91
+ */
92
+ export type ValidateLocationState<T> = [T] extends [LocationStateSafe<T>]
93
+ ? unknown
94
+ : LocationStateUnsafe<"location state must be serializable: React/RSC content, functions, and symbols cannot be stored — pass plain data and render it on arrival">;
95
+
25
96
  /**
26
97
  * Type-safe location state definition
27
98
  *
@@ -34,8 +105,43 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
34
105
  __rsc_ls_key: string;
35
106
  /** Whether this state auto-clears after first read */
36
107
  readonly __rsc_ls_flash: boolean;
37
- /** Read the current value from history.state (client-side only, undefined during SSR) */
108
+ /**
109
+ * Read the current value from history.state.
110
+ *
111
+ * Returns undefined during SSR (no `window`). To stay hydration-safe, do
112
+ * NOT call read() inline during the initial render — the server returns
113
+ * undefined while the client may have a value preserved in history.state
114
+ * (e.g. after a hard reload of an entry that earlier called write()),
115
+ * which causes a hydration mismatch. Call read() inside an event handler
116
+ * or a useEffect post-mount instead, or use useLocationState() if you
117
+ * want React to manage subscription/hydration for you.
118
+ */
38
119
  read(): TState | undefined;
120
+ /**
121
+ * Statically write the value into the current history entry under this
122
+ * definition's key, preserving any other keys already on history.state
123
+ * (e.g. router bookkeeping, other LocationState slots).
124
+ *
125
+ * This is the non-reactive counterpart to read(): it does not dispatch any
126
+ * event, so components reading via useLocationState() will NOT re-render
127
+ * until the next navigation/popstate. Use it when you only need the value
128
+ * to be there on the next read() or on the next mount (including after
129
+ * back/forward and hard refresh of the same entry).
130
+ *
131
+ * Client-only: throws when called on the server (no history available).
132
+ */
133
+ write(value: TState & ValidateLocationState<TState>): void;
134
+ /**
135
+ * Statically remove this definition's slot from the current history entry,
136
+ * leaving any other keys on history.state untouched. Idempotent: removing
137
+ * a slot that isn't present is a no-op.
138
+ *
139
+ * Same non-reactive semantics as write(): no event is dispatched, so
140
+ * useLocationState() readers will NOT re-render until the next navigation.
141
+ *
142
+ * Client-only: throws when called on the server (no history available).
143
+ */
144
+ delete(): void;
39
145
  }
40
146
 
41
147
  /**
@@ -70,18 +176,30 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
70
176
  *
71
177
  * // Read without hook (snapshot, client-side only)
72
178
  * const snap = ProductState.read();
179
+ *
180
+ * // Static write to current history entry (non-reactive, client-side only).
181
+ * // Survives back/forward and hard refresh; useLocationState() readers will
182
+ * // NOT see the new value until the next navigation. Pair with .read() or a
183
+ * // fresh mount.
184
+ * ProductState.write({ name: "Widget", price: 9.99 });
185
+ *
186
+ * // Manually clear the slot (non-reactive, client-side only).
187
+ * ProductState.delete();
73
188
  * ```
74
189
  */
75
190
  export function createLocationState<TState>(
76
191
  options?: LocationStateOptions,
77
- ): LocationStateDefinition<[TState | (() => TState)], TState> {
192
+ ): LocationStateDefinition<
193
+ [(TState | (() => TState)) & ValidateLocationState<TState>],
194
+ TState
195
+ > {
78
196
  const flash = options?.flash ?? false;
79
197
  let _key: string | undefined;
80
198
 
81
199
  function getKey(): string {
82
200
  if (!_key && process.env.NODE_ENV === "development") {
83
201
  throw new Error(
84
- "[rsc-router] createLocationState key not set. " +
202
+ "[rango] createLocationState key not set. " +
85
203
  "Make sure the exposeInternalIds Vite plugin is enabled and " +
86
204
  "the state is exported with: export const MyState = createLocationState(...)",
87
205
  );
@@ -128,7 +246,59 @@ export function createLocationState<TState>(
128
246
  enumerable: true,
129
247
  });
130
248
 
131
- return fn as LocationStateDefinition<[TState | (() => TState)], TState>;
249
+ Object.defineProperty(fn, "write", {
250
+ value: (value: TState): void => {
251
+ if (typeof window === "undefined") {
252
+ throw new Error(
253
+ "[rango] LocationState.write() is client-only. " +
254
+ "It mutates window.history.state and cannot run on the server.",
255
+ );
256
+ }
257
+ const key = getKey();
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 : {};
266
+ window.history.replaceState(
267
+ { ...current, [key]: value },
268
+ "",
269
+ window.location.href,
270
+ );
271
+ },
272
+ enumerable: true,
273
+ });
274
+
275
+ Object.defineProperty(fn, "delete", {
276
+ value: (): void => {
277
+ if (typeof window === "undefined") {
278
+ throw new Error(
279
+ "[rango] LocationState.delete() is client-only. " +
280
+ "It mutates window.history.state and cannot run on the server.",
281
+ );
282
+ }
283
+ const key = getKey();
284
+ const current = window.history.state;
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;
291
+ const next = { ...current };
292
+ delete next[key];
293
+ window.history.replaceState(next, "", window.location.href);
294
+ },
295
+ enumerable: true,
296
+ });
297
+
298
+ return fn as unknown as LocationStateDefinition<
299
+ [(TState | (() => TState)) & ValidateLocationState<TState>],
300
+ TState
301
+ >;
132
302
  }
133
303
 
134
304
  /**
@@ -1,9 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect } from "react";
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,
@@ -13,6 +12,24 @@ export {
13
12
  type LocationStateOptions,
14
13
  } from "./location-state-shared.js";
15
14
 
15
+ function readLocationStateValue<TState>(
16
+ key: string | undefined,
17
+ ): TState | undefined {
18
+ if (typeof window === "undefined") return undefined;
19
+ if (key) {
20
+ return window.history.state?.[key] as TState | undefined;
21
+ }
22
+ // Plain state: stored under history.state.state
23
+ return window.history.state?.state as TState | undefined;
24
+ }
25
+
26
+ function hasHydrated(): boolean {
27
+ return (
28
+ typeof document !== "undefined" &&
29
+ document.documentElement.hasAttribute("data-hydrated")
30
+ );
31
+ }
32
+
16
33
  /**
17
34
  * Hook to read location state from history.state
18
35
  *
@@ -48,30 +65,33 @@ export function useLocationState<TArgs extends unknown[], TState>(
48
65
  const key = definition?.__rsc_ls_key;
49
66
  const isFlash = definition?.__rsc_ls_flash ?? false;
50
67
 
68
+ // Track whether the initial render returned undefined because the page
69
+ // hadn't hydrated yet. If so, the mount effect catches up by reading
70
+ // history.state once. If not, we already have the right value and must
71
+ // not re-read on mount — under StrictMode, the flash-cleanup effect runs
72
+ // before the second setup pass, so a re-read would clobber the captured
73
+ // value with the now-cleared `undefined`.
74
+ const initialReadDeferredRef = useRef(false);
75
+
51
76
  const [state, setState] = useState<TState | undefined>(() => {
52
- if (typeof window === "undefined") return undefined;
53
- if (key) {
54
- return window.history.state?.[key] as TState | undefined;
77
+ if (!hasHydrated()) {
78
+ initialReadDeferredRef.current = true;
79
+ return undefined;
55
80
  }
56
- // Plain state: stored under history.state.state
57
- return window.history.state?.state as TState | undefined;
81
+ return readLocationStateValue<TState>(key);
58
82
  });
59
83
 
60
84
  // Subscribe to popstate and programmatic state changes
61
85
  useEffect(() => {
62
86
  const handlePopstate = () => {
63
- if (key) {
64
- setState(window.history.state?.[key] as TState | undefined);
65
- } else {
66
- setState(window.history.state?.state as TState | undefined);
67
- }
87
+ setState(readLocationStateValue<TState>(key));
68
88
  };
69
89
 
70
90
  // Handle programmatic state changes (same-page navigation with
71
91
  // ctx.setLocationState where components don't remount)
72
92
  const handleLocationState = () => {
73
93
  if (key) {
74
- const val = window.history.state?.[key] as TState | undefined;
94
+ const val = readLocationStateValue<TState>(key);
75
95
  if (isFlash) {
76
96
  // For flash state, only update if there's a new value
77
97
  if (val !== undefined) {
@@ -81,10 +101,15 @@ export function useLocationState<TArgs extends unknown[], TState>(
81
101
  setState(val);
82
102
  }
83
103
  } else {
84
- setState(window.history.state?.state as TState | undefined);
104
+ setState(readLocationStateValue<TState>(key));
85
105
  }
86
106
  };
87
107
 
108
+ if (initialReadDeferredRef.current) {
109
+ initialReadDeferredRef.current = false;
110
+ setState(readLocationStateValue<TState>(key));
111
+ }
112
+
88
113
  window.addEventListener("popstate", handlePopstate);
89
114
  window.addEventListener("__rsc_locationstate", handleLocationState);
90
115
  return () => {
@@ -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!);
@@ -32,40 +32,43 @@ import { shallowEqual } from "./shallow-equal.js";
32
32
  * const lastCrumb = useHandle(Breadcrumbs, (data) => data.at(-1));
33
33
  * ```
34
34
  */
35
- export function useHandle<T, A>(handle: Handle<T, A>): A;
35
+ export function useHandle<T, A>(handle: Handle<T, A>): Rango.FlightSerialize<A>;
36
36
  export function useHandle<T, A, S>(
37
37
  handle: Handle<T, A>,
38
- selector: (data: A) => S,
38
+ selector: (data: Rango.FlightSerialize<A>) => S,
39
39
  ): S;
40
40
  export function useHandle<T, A, S>(
41
41
  handle: Handle<T, A>,
42
- selector?: (data: A) => S,
43
- ): A | S {
42
+ selector?: (data: Rango.FlightSerialize<A>) => S,
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
- const [value, setValue] = useState<A | S>(() => {
46
+ const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
48
47
  if (!ctx) {
49
- const collected = collectHandleData(handle, {}, []);
48
+ const collected = collectHandleData(
49
+ handle,
50
+ {},
51
+ [],
52
+ ) as Rango.FlightSerialize<A>;
50
53
  return selector ? selector(collected) : collected;
51
54
  }
52
55
 
53
- // On client, use event controller state
54
56
  const state = ctx.eventController.getHandleState();
55
- const collected = collectHandleData(handle, state.data, state.segmentOrder);
57
+ const collected = collectHandleData(
58
+ handle,
59
+ state.data,
60
+ state.segmentOrder,
61
+ ) as Rango.FlightSerialize<A>;
56
62
  return selector ? selector(collected) : collected;
57
63
  });
58
64
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
59
65
 
60
- // Track previous value for shallow comparison
61
66
  const prevValueRef = useRef(value);
62
67
  prevValueRef.current = value;
63
68
 
64
- // Ref keeps the latest selector without re-subscribing on every render.
65
69
  const selectorRef = useRef(selector);
66
70
  selectorRef.current = selector;
67
71
 
68
- // Subscribe to handle data changes (client only)
69
72
  useEffect(() => {
70
73
  if (!ctx) return;
71
74
 
@@ -76,7 +79,7 @@ export function useHandle<T, A, S>(
76
79
  handle,
77
80
  currentHandleState.data,
78
81
  currentHandleState.segmentOrder,
79
- );
82
+ ) as Rango.FlightSerialize<A>;
80
83
  const currentValue = selectorRef.current
81
84
  ? selectorRef.current(currentCollected)
82
85
  : currentCollected;
@@ -93,7 +96,7 @@ export function useHandle<T, A, S>(
93
96
  handle,
94
97
  state.data,
95
98
  state.segmentOrder,
96
- );
99
+ ) as Rango.FlightSerialize<A>;
97
100
  const nextValue = selectorRef.current
98
101
  ? selectorRef.current(collected)
99
102
  : collected;
@@ -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
  }
@@ -82,11 +82,9 @@ export function useLinkStatus(): LinkStatus {
82
82
  const linkTo = useContext(LinkContext);
83
83
  const ctx = useContext(NavigationStoreContext);
84
84
 
85
- // Get origin for URL normalization (stable across renders)
86
85
  const origin =
87
86
  typeof window !== "undefined" ? window.location.origin : "http://localhost";
88
87
 
89
- // Base state for useOptimistic
90
88
  const [basePending, setBasePending] = useState<boolean>(() => {
91
89
  if (!ctx || linkTo === null) {
92
90
  return false;
@@ -97,7 +95,13 @@ export function useLinkStatus(): LinkStatus {
97
95
 
98
96
  const prevPending = useRef(basePending);
99
97
 
100
- // useOptimistic allows immediate updates during transitions
98
+ // Tracks whether the most recent setOptimisticPending call pinned the value
99
+ // to a non-idle (loading) state. Used to decide whether to emit a release
100
+ // update when returning to idle, so the optimistic store doesn't stay pinned
101
+ // to `true` if a parent transition (e.g. the <Link> click / view transition
102
+ // commit) is still pending. Mirrors useNavigation's optimisticPinnedRef.
103
+ const optimisticPinnedRef = useRef(false);
104
+
101
105
  const [pending, setOptimisticPending] = useOptimistic(basePending);
102
106
 
103
107
  useEffect(() => {
@@ -105,25 +109,46 @@ export function useLinkStatus(): LinkStatus {
105
109
  return;
106
110
  }
107
111
 
108
- // Subscribe to navigation state changes
109
- return ctx.eventController.subscribe(() => {
112
+ const update = () => {
110
113
  const state = ctx.eventController.getState();
111
114
  const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
112
115
 
113
116
  if (isPending !== prevPending.current) {
114
117
  prevPending.current = isPending;
115
118
 
116
- // Use optimistic update for immediate feedback during navigation
117
- if (state.state !== "idle") {
119
+ const shouldPin = isPending && state.state !== "idle";
120
+
121
+ if (shouldPin) {
122
+ // Pin the optimistic value so the spinner shows immediately even if
123
+ // a parent transition (e.g. <Link> click) defers the urgent
124
+ // setBasePending commit.
118
125
  startTransition(() => {
119
126
  setOptimisticPending(isPending);
120
127
  });
128
+ optimisticPinnedRef.current = true;
129
+ } else if (optimisticPinnedRef.current) {
130
+ // Release a previously-pinned optimistic value. Without this,
131
+ // useOptimistic keeps returning the stale `true` while any parent
132
+ // transition is still pending, even after basePending flipped to
133
+ // false at navigation completion — leaving the link spinner stuck.
134
+ startTransition(() => {
135
+ setOptimisticPending(isPending);
136
+ });
137
+ optimisticPinnedRef.current = false;
121
138
  }
122
139
 
123
140
  // Always update base state
124
141
  setBasePending(isPending);
125
142
  }
126
- });
143
+ };
144
+
145
+ // Catch-up: re-read state synchronously on mount before subscribing, so a
146
+ // navigation that started between the seeding render and this effect commit
147
+ // isn't dropped until the next (debounced) notify. Mirrors usePathname /
148
+ // useSearchParams.
149
+ update();
150
+
151
+ return ctx.eventController.subscribe(update);
127
152
  }, [linkTo, origin]);
128
153
 
129
154
  // If not inside a Link, return not pending