@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.d98a8e9d

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 (278) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2154 -861
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +220 -30
  11. package/skills/caching/SKILL.md +116 -8
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +71 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +243 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +57 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +128 -0
  37. package/skills/testing/bindings.md +89 -0
  38. package/skills/testing/cache-prerender.md +98 -0
  39. package/skills/testing/client-components.md +121 -0
  40. package/skills/testing/e2e-parity.md +124 -0
  41. package/skills/testing/flight.md +89 -0
  42. package/skills/testing/handles.md +127 -0
  43. package/skills/testing/loader.md +108 -0
  44. package/skills/testing/middleware.md +97 -0
  45. package/skills/testing/render-handler.md +102 -0
  46. package/skills/testing/response-routes.md +94 -0
  47. package/skills/testing/reverse-and-types.md +83 -0
  48. package/skills/testing/server-actions.md +89 -0
  49. package/skills/testing/server-tree.md +128 -0
  50. package/skills/testing/setup.md +120 -0
  51. package/skills/typesafety/SKILL.md +319 -27
  52. package/skills/use-cache/SKILL.md +34 -5
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/browser/action-coordinator.ts +53 -36
  57. package/src/browser/app-shell.ts +52 -0
  58. package/src/browser/event-controller.ts +86 -70
  59. package/src/browser/history-state.ts +21 -0
  60. package/src/browser/index.ts +3 -3
  61. package/src/browser/navigation-bridge.ts +84 -11
  62. package/src/browser/navigation-client.ts +104 -68
  63. package/src/browser/navigation-store.ts +32 -9
  64. package/src/browser/navigation-transaction.ts +10 -28
  65. package/src/browser/partial-update.ts +64 -26
  66. package/src/browser/prefetch/cache.ts +183 -44
  67. package/src/browser/prefetch/fetch.ts +228 -37
  68. package/src/browser/prefetch/queue.ts +36 -5
  69. package/src/browser/rango-state.ts +53 -13
  70. package/src/browser/react/Link.tsx +30 -2
  71. package/src/browser/react/NavigationProvider.tsx +72 -31
  72. package/src/browser/react/filter-segment-order.ts +51 -7
  73. package/src/browser/react/index.ts +3 -0
  74. package/src/browser/react/location-state-shared.ts +175 -4
  75. package/src/browser/react/location-state.ts +39 -13
  76. package/src/browser/react/use-handle.ts +17 -9
  77. package/src/browser/react/use-navigation.ts +22 -2
  78. package/src/browser/react/use-params.ts +20 -8
  79. package/src/browser/react/use-reverse.ts +106 -0
  80. package/src/browser/react/use-router.ts +22 -2
  81. package/src/browser/react/use-segments.ts +11 -8
  82. package/src/browser/response-adapter.ts +32 -1
  83. package/src/browser/rsc-router.tsx +69 -22
  84. package/src/browser/scroll-restoration.ts +22 -14
  85. package/src/browser/segment-reconciler.ts +36 -14
  86. package/src/browser/segment-structure-assert.ts +2 -2
  87. package/src/browser/server-action-bridge.ts +23 -30
  88. package/src/browser/types.ts +21 -0
  89. package/src/build/collect-fallback-refs.ts +107 -0
  90. package/src/build/generate-manifest.ts +60 -35
  91. package/src/build/generate-route-types.ts +2 -0
  92. package/src/build/index.ts +8 -1
  93. package/src/build/prefix-tree-utils.ts +123 -0
  94. package/src/build/route-trie.ts +95 -25
  95. package/src/build/route-types/codegen.ts +4 -4
  96. package/src/build/route-types/include-resolution.ts +1 -1
  97. package/src/build/route-types/per-module-writer.ts +7 -4
  98. package/src/build/route-types/router-processing.ts +55 -14
  99. package/src/build/route-types/scan-filter.ts +1 -1
  100. package/src/build/route-types/source-scan.ts +118 -0
  101. package/src/build/runtime-discovery.ts +9 -20
  102. package/src/cache/cache-scope.ts +28 -42
  103. package/src/cache/cf/cf-cache-store.ts +54 -13
  104. package/src/client.rsc.tsx +3 -0
  105. package/src/client.tsx +96 -205
  106. package/src/context-var.ts +5 -5
  107. package/src/decode-loader-results.ts +36 -0
  108. package/src/errors.ts +30 -4
  109. package/src/handle.ts +32 -14
  110. package/src/host/index.ts +2 -2
  111. package/src/host/router.ts +129 -57
  112. package/src/host/types.ts +31 -2
  113. package/src/host/utils.ts +1 -1
  114. package/src/href-client.ts +140 -21
  115. package/src/index.rsc.ts +10 -6
  116. package/src/index.ts +54 -17
  117. package/src/loader-store.ts +500 -0
  118. package/src/loader.rsc.ts +25 -7
  119. package/src/loader.ts +16 -9
  120. package/src/missing-id-error.ts +68 -0
  121. package/src/outlet-context.ts +1 -1
  122. package/src/prerender.ts +27 -6
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -36
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +384 -257
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +100 -28
  129. package/src/route-definition/resolve-handler-use.ts +6 -0
  130. package/src/route-definition/use-item-types.ts +32 -0
  131. package/src/route-types.ts +26 -41
  132. package/src/router/basename.ts +14 -0
  133. package/src/router/content-negotiation.ts +15 -2
  134. package/src/router/error-handling.ts +1 -1
  135. package/src/router/find-match.ts +54 -6
  136. package/src/router/handler-context.ts +21 -38
  137. package/src/router/intercept-resolution.ts +4 -18
  138. package/src/router/lazy-includes.ts +41 -22
  139. package/src/router/loader-resolution.ts +82 -36
  140. package/src/router/manifest.ts +41 -19
  141. package/src/router/match-api.ts +4 -3
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/cache-lookup.ts +44 -91
  144. package/src/router/match-middleware/cache-store.ts +3 -2
  145. package/src/router/match-result.ts +53 -32
  146. package/src/router/metrics.ts +1 -1
  147. package/src/router/middleware-types.ts +15 -26
  148. package/src/router/middleware.ts +99 -84
  149. package/src/router/pattern-matching.ts +116 -19
  150. package/src/router/prerender-match.ts +1 -1
  151. package/src/router/preview-match.ts +3 -1
  152. package/src/router/request-classification.ts +4 -28
  153. package/src/router/revalidation.ts +58 -2
  154. package/src/router/router-interfaces.ts +45 -28
  155. package/src/router/router-options.ts +40 -1
  156. package/src/router/router-registry.ts +2 -5
  157. package/src/router/segment-resolution/fresh.ts +27 -6
  158. package/src/router/segment-resolution/revalidation.ts +147 -106
  159. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  160. package/src/router/substitute-pattern-params.ts +56 -0
  161. package/src/router/telemetry.ts +99 -0
  162. package/src/router/trie-matching.ts +40 -16
  163. package/src/router/types.ts +8 -0
  164. package/src/router/url-params.ts +49 -0
  165. package/src/router.ts +52 -30
  166. package/src/rsc/handler-context.ts +2 -2
  167. package/src/rsc/handler.ts +28 -69
  168. package/src/rsc/helpers.ts +91 -43
  169. package/src/rsc/index.ts +1 -1
  170. package/src/rsc/manifest-init.ts +28 -41
  171. package/src/rsc/origin-guard.ts +28 -10
  172. package/src/rsc/progressive-enhancement.ts +4 -0
  173. package/src/rsc/response-error.ts +79 -12
  174. package/src/rsc/response-route-handler.ts +57 -61
  175. package/src/rsc/rsc-rendering.ts +35 -51
  176. package/src/rsc/runtime-warnings.ts +9 -10
  177. package/src/rsc/server-action.ts +17 -37
  178. package/src/rsc/ssr-setup.ts +16 -0
  179. package/src/rsc/types.ts +8 -2
  180. package/src/runtime-env.ts +18 -0
  181. package/src/search-params.ts +4 -4
  182. package/src/segment-content-promise.ts +67 -0
  183. package/src/segment-loader-promise.ts +122 -0
  184. package/src/segment-system.tsx +132 -116
  185. package/src/serialize.ts +243 -0
  186. package/src/server/context.ts +175 -53
  187. package/src/server/cookie-store.ts +28 -4
  188. package/src/server/request-context.ts +67 -51
  189. package/src/ssr/index.tsx +5 -1
  190. package/src/static-handler.ts +25 -3
  191. package/src/testing/cache-status.ts +166 -0
  192. package/src/testing/collect-handle.ts +63 -0
  193. package/src/testing/dispatch.ts +581 -0
  194. package/src/testing/dom.entry.ts +22 -0
  195. package/src/testing/e2e/fixture.ts +188 -0
  196. package/src/testing/e2e/index.ts +149 -0
  197. package/src/testing/e2e/matchers.ts +51 -0
  198. package/src/testing/e2e/page-helpers.ts +272 -0
  199. package/src/testing/e2e/parity.ts +326 -0
  200. package/src/testing/e2e/server.ts +195 -0
  201. package/src/testing/flight-matchers.ts +110 -0
  202. package/src/testing/flight-normalize.ts +38 -0
  203. package/src/testing/flight-runtime.d.ts +57 -0
  204. package/src/testing/flight-tree.ts +682 -0
  205. package/src/testing/flight.entry.ts +51 -0
  206. package/src/testing/flight.ts +234 -0
  207. package/src/testing/generated-routes.ts +223 -0
  208. package/src/testing/index.ts +106 -0
  209. package/src/testing/internal/context.ts +304 -0
  210. package/src/testing/internal/flight-client-globals.ts +30 -0
  211. package/src/testing/internal/seed-vars.ts +42 -0
  212. package/src/testing/render-handler.ts +323 -0
  213. package/src/testing/render-route.tsx +590 -0
  214. package/src/testing/run-loader.ts +363 -0
  215. package/src/testing/run-middleware.ts +205 -0
  216. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  217. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  218. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  219. package/src/testing/vitest-stubs/version.ts +5 -0
  220. package/src/testing/vitest.ts +285 -0
  221. package/src/types/global-namespace.ts +39 -26
  222. package/src/types/handler-context.ts +68 -50
  223. package/src/types/index.ts +1 -0
  224. package/src/types/loader-types.ts +11 -9
  225. package/src/types/request-scope.ts +126 -0
  226. package/src/types/route-entry.ts +11 -0
  227. package/src/types/segments.ts +35 -2
  228. package/src/urls/include-helper.ts +34 -67
  229. package/src/urls/index.ts +1 -5
  230. package/src/urls/path-helper-types.ts +41 -7
  231. package/src/urls/path-helper.ts +17 -52
  232. package/src/urls/pattern-types.ts +36 -19
  233. package/src/urls/response-types.ts +22 -29
  234. package/src/urls/type-extraction.ts +58 -139
  235. package/src/urls/urls-function.ts +1 -5
  236. package/src/use-loader.tsx +413 -42
  237. package/src/vite/debug.ts +185 -0
  238. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  239. package/src/vite/discovery/discover-routers.ts +106 -75
  240. package/src/vite/discovery/discovery-errors.ts +194 -0
  241. package/src/vite/discovery/gate-state.ts +171 -0
  242. package/src/vite/discovery/prerender-collection.ts +67 -26
  243. package/src/vite/discovery/route-types-writer.ts +40 -84
  244. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  245. package/src/vite/discovery/state.ts +33 -0
  246. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  247. package/src/vite/index.ts +2 -0
  248. package/src/vite/plugin-types.ts +67 -0
  249. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  250. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  251. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  252. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  253. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  254. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  255. package/src/vite/plugins/expose-action-id.ts +54 -30
  256. package/src/vite/plugins/expose-id-utils.ts +12 -8
  257. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  258. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  259. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  260. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  261. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  262. package/src/vite/plugins/performance-tracks.ts +29 -25
  263. package/src/vite/plugins/use-cache-transform.ts +65 -50
  264. package/src/vite/plugins/version-injector.ts +39 -23
  265. package/src/vite/plugins/version-plugin.ts +59 -2
  266. package/src/vite/plugins/virtual-entries.ts +2 -2
  267. package/src/vite/rango.ts +116 -29
  268. package/src/vite/router-discovery.ts +750 -100
  269. package/src/vite/utils/ast-handler-extract.ts +15 -15
  270. package/src/vite/utils/banner.ts +1 -1
  271. package/src/vite/utils/bundle-analysis.ts +4 -2
  272. package/src/vite/utils/client-chunks.ts +190 -0
  273. package/src/vite/utils/forward-user-plugins.ts +193 -0
  274. package/src/vite/utils/manifest-utils.ts +8 -59
  275. package/src/vite/utils/package-resolution.ts +41 -1
  276. package/src/vite/utils/prerender-utils.ts +21 -6
  277. package/src/vite/utils/shared-utils.ts +107 -26
  278. package/src/browser/action-response-classifier.ts +0 -99
@@ -3,6 +3,8 @@
3
3
  * No "use client" directive so it can be imported from RSC
4
4
  */
5
5
 
6
+ import type { ReactElement } from "react";
7
+
6
8
  /**
7
9
  * Internal entry representing a state value with its unique key.
8
10
  * When __rsc_ls_lazy is true, __rsc_ls_value holds a getter function
@@ -22,6 +24,88 @@ export interface LocationStateOptions {
22
24
  flash?: boolean;
23
25
  }
24
26
 
27
+ type LocationStateUnsafeFn = (...args: never[]) => unknown;
28
+
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
+ type LocationStateUnsafeCtor = abstract new (...args: never[]) => unknown;
34
+
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
+ type IsAny<T> = 0 extends 1 & T ? true : false;
40
+ type IsUnknown<T> =
41
+ IsAny<T> extends true ? false : unknown extends T ? true : false;
42
+
43
+ /**
44
+ * Branded error surfaced when a value that cannot live in location state is
45
+ * used. Location state is written into `history.state`, which uses the
46
+ * structured clone algorithm; React elements, functions, and symbols throw a
47
+ * `DataCloneError` at runtime. Carries a human-readable reason so the compile
48
+ * error explains the fix.
49
+ */
50
+ export type LocationStateUnsafe<Reason extends string> = {
51
+ readonly __rango_location_state_unsafe: Reason;
52
+ };
53
+
54
+ /**
55
+ * Maps `T` to itself when it is safe to store in location state, or to a branded
56
+ * {@link LocationStateUnsafe} error for the disallowed parts: `unknown`, React
57
+ * elements (RSC/JSX content), functions, class constructors, and symbols.
58
+ * Recurses through arrays, `Map`, `Set`, and plain objects; structured-clone
59
+ * built-ins (`Date`, `RegExp`, typed arrays, `Blob`, `File`, `FormData`) pass
60
+ * through. Consumed by {@link ValidateLocationState}, which is intersected into a
61
+ * definition's value parameter so posting RSC content is a COMPILE error, not a
62
+ * runtime `DataCloneError`. (`any` is unguardable and remains an escape hatch.)
63
+ */
64
+ export type LocationStateSafe<T> =
65
+ IsUnknown<T> extends true
66
+ ? LocationStateUnsafe<"location state needs an explicit, concrete type; `unknown` cannot be verified as serializable">
67
+ : T extends LocationStateUnsafeFn
68
+ ? LocationStateUnsafe<"functions cannot be stored in location state">
69
+ : T extends LocationStateUnsafeCtor
70
+ ? LocationStateUnsafe<"class constructors cannot be stored in location state">
71
+ : T extends symbol
72
+ ? LocationStateUnsafe<"symbols cannot be stored in location state">
73
+ : T extends ReactElement
74
+ ? LocationStateUnsafe<"React/RSC content cannot be stored in location state; store plain data and render it on arrival">
75
+ : T extends string | number | boolean | bigint | null | undefined
76
+ ? T
77
+ : T extends
78
+ | Date
79
+ | RegExp
80
+ | ArrayBuffer
81
+ | ArrayBufferView
82
+ | Blob
83
+ | File
84
+ | FormData
85
+ ? T
86
+ : T extends ReadonlyMap<infer K, infer V>
87
+ ? ReadonlyMap<LocationStateSafe<K>, LocationStateSafe<V>>
88
+ : T extends ReadonlySet<infer V>
89
+ ? ReadonlySet<LocationStateSafe<V>>
90
+ : T extends readonly unknown[]
91
+ ? { [K in keyof T]: LocationStateSafe<T[K]> }
92
+ : T extends object
93
+ ? { [K in keyof T]: LocationStateSafe<T[K]> }
94
+ : T;
95
+
96
+ /**
97
+ * `unknown` (a no-op) when `T` is safe to store in location state, otherwise a
98
+ * branded {@link LocationStateUnsafe} object. Intersected into the value
99
+ * parameter of a definition's call and `write()` so POSTING RSC content (or any
100
+ * non-serializable value) is a compile error whose text carries the reason —
101
+ * without a `TState extends ...` self-constraint, which TypeScript rejects as
102
+ * circular (TS2313). For safe `T`, `value & unknown` collapses back to `value`,
103
+ * so valid usage is unchanged.
104
+ */
105
+ export type ValidateLocationState<T> = [T] extends [LocationStateSafe<T>]
106
+ ? unknown
107
+ : LocationStateUnsafe<"location state must be serializable: React/RSC content, functions, and symbols cannot be stored — pass plain data and render it on arrival">;
108
+
25
109
  /**
26
110
  * Type-safe location state definition
27
111
  *
@@ -34,8 +118,43 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
34
118
  __rsc_ls_key: string;
35
119
  /** Whether this state auto-clears after first read */
36
120
  readonly __rsc_ls_flash: boolean;
37
- /** Read the current value from history.state (client-side only, undefined during SSR) */
121
+ /**
122
+ * Read the current value from history.state.
123
+ *
124
+ * Returns undefined during SSR (no `window`). To stay hydration-safe, do
125
+ * NOT call read() inline during the initial render — the server returns
126
+ * undefined while the client may have a value preserved in history.state
127
+ * (e.g. after a hard reload of an entry that earlier called write()),
128
+ * which causes a hydration mismatch. Call read() inside an event handler
129
+ * or a useEffect post-mount instead, or use useLocationState() if you
130
+ * want React to manage subscription/hydration for you.
131
+ */
38
132
  read(): TState | undefined;
133
+ /**
134
+ * Statically write the value into the current history entry under this
135
+ * definition's key, preserving any other keys already on history.state
136
+ * (e.g. router bookkeeping, other LocationState slots).
137
+ *
138
+ * This is the non-reactive counterpart to read(): it does not dispatch any
139
+ * event, so components reading via useLocationState() will NOT re-render
140
+ * until the next navigation/popstate. Use it when you only need the value
141
+ * to be there on the next read() or on the next mount (including after
142
+ * back/forward and hard refresh of the same entry).
143
+ *
144
+ * Client-only: throws when called on the server (no history available).
145
+ */
146
+ write(value: TState & ValidateLocationState<TState>): void;
147
+ /**
148
+ * Statically remove this definition's slot from the current history entry,
149
+ * leaving any other keys on history.state untouched. Idempotent: removing
150
+ * a slot that isn't present is a no-op.
151
+ *
152
+ * Same non-reactive semantics as write(): no event is dispatched, so
153
+ * useLocationState() readers will NOT re-render until the next navigation.
154
+ *
155
+ * Client-only: throws when called on the server (no history available).
156
+ */
157
+ delete(): void;
39
158
  }
40
159
 
41
160
  /**
@@ -70,18 +189,30 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
70
189
  *
71
190
  * // Read without hook (snapshot, client-side only)
72
191
  * const snap = ProductState.read();
192
+ *
193
+ * // Static write to current history entry (non-reactive, client-side only).
194
+ * // Survives back/forward and hard refresh; useLocationState() readers will
195
+ * // NOT see the new value until the next navigation. Pair with .read() or a
196
+ * // fresh mount.
197
+ * ProductState.write({ name: "Widget", price: 9.99 });
198
+ *
199
+ * // Manually clear the slot (non-reactive, client-side only).
200
+ * ProductState.delete();
73
201
  * ```
74
202
  */
75
203
  export function createLocationState<TState>(
76
204
  options?: LocationStateOptions,
77
- ): LocationStateDefinition<[TState | (() => TState)], TState> {
205
+ ): LocationStateDefinition<
206
+ [(TState | (() => TState)) & ValidateLocationState<TState>],
207
+ TState
208
+ > {
78
209
  const flash = options?.flash ?? false;
79
210
  let _key: string | undefined;
80
211
 
81
212
  function getKey(): string {
82
213
  if (!_key && process.env.NODE_ENV === "development") {
83
214
  throw new Error(
84
- "[rsc-router] createLocationState key not set. " +
215
+ "[rango] createLocationState key not set. " +
85
216
  "Make sure the exposeInternalIds Vite plugin is enabled and " +
86
217
  "the state is exported with: export const MyState = createLocationState(...)",
87
218
  );
@@ -128,7 +259,47 @@ export function createLocationState<TState>(
128
259
  enumerable: true,
129
260
  });
130
261
 
131
- return fn as LocationStateDefinition<[TState | (() => TState)], TState>;
262
+ Object.defineProperty(fn, "write", {
263
+ value: (value: TState): void => {
264
+ if (typeof window === "undefined") {
265
+ throw new Error(
266
+ "[rango] LocationState.write() is client-only. " +
267
+ "It mutates window.history.state and cannot run on the server.",
268
+ );
269
+ }
270
+ const key = getKey();
271
+ const current = window.history.state ?? {};
272
+ window.history.replaceState(
273
+ { ...current, [key]: value },
274
+ "",
275
+ window.location.href,
276
+ );
277
+ },
278
+ enumerable: true,
279
+ });
280
+
281
+ Object.defineProperty(fn, "delete", {
282
+ value: (): void => {
283
+ if (typeof window === "undefined") {
284
+ throw new Error(
285
+ "[rango] LocationState.delete() is client-only. " +
286
+ "It mutates window.history.state and cannot run on the server.",
287
+ );
288
+ }
289
+ const key = getKey();
290
+ const current = window.history.state;
291
+ if (current == null || !(key in current)) return;
292
+ const next = { ...current };
293
+ delete next[key];
294
+ window.history.replaceState(next, "", window.location.href);
295
+ },
296
+ enumerable: true,
297
+ });
298
+
299
+ return fn as unknown as LocationStateDefinition<
300
+ [(TState | (() => TState)) & ValidateLocationState<TState>],
301
+ TState
302
+ >;
132
303
  }
133
304
 
134
305
  /**
@@ -1,6 +1,6 @@
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
6
  // Re-export shared utilities and types
@@ -13,6 +13,24 @@ export {
13
13
  type LocationStateOptions,
14
14
  } from "./location-state-shared.js";
15
15
 
16
+ function readLocationStateValue<TState>(
17
+ key: string | undefined,
18
+ ): TState | undefined {
19
+ if (typeof window === "undefined") return undefined;
20
+ if (key) {
21
+ return window.history.state?.[key] as TState | undefined;
22
+ }
23
+ // Plain state: stored under history.state.state
24
+ return window.history.state?.state as TState | undefined;
25
+ }
26
+
27
+ function hasHydrated(): boolean {
28
+ return (
29
+ typeof document !== "undefined" &&
30
+ document.documentElement.hasAttribute("data-hydrated")
31
+ );
32
+ }
33
+
16
34
  /**
17
35
  * Hook to read location state from history.state
18
36
  *
@@ -48,30 +66,33 @@ export function useLocationState<TArgs extends unknown[], TState>(
48
66
  const key = definition?.__rsc_ls_key;
49
67
  const isFlash = definition?.__rsc_ls_flash ?? false;
50
68
 
69
+ // Track whether the initial render returned undefined because the page
70
+ // hadn't hydrated yet. If so, the mount effect catches up by reading
71
+ // history.state once. If not, we already have the right value and must
72
+ // not re-read on mount — under StrictMode, the flash-cleanup effect runs
73
+ // before the second setup pass, so a re-read would clobber the captured
74
+ // value with the now-cleared `undefined`.
75
+ const initialReadDeferredRef = useRef(false);
76
+
51
77
  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;
78
+ if (!hasHydrated()) {
79
+ initialReadDeferredRef.current = true;
80
+ return undefined;
55
81
  }
56
- // Plain state: stored under history.state.state
57
- return window.history.state?.state as TState | undefined;
82
+ return readLocationStateValue<TState>(key);
58
83
  });
59
84
 
60
85
  // Subscribe to popstate and programmatic state changes
61
86
  useEffect(() => {
62
87
  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
- }
88
+ setState(readLocationStateValue<TState>(key));
68
89
  };
69
90
 
70
91
  // Handle programmatic state changes (same-page navigation with
71
92
  // ctx.setLocationState where components don't remount)
72
93
  const handleLocationState = () => {
73
94
  if (key) {
74
- const val = window.history.state?.[key] as TState | undefined;
95
+ const val = readLocationStateValue<TState>(key);
75
96
  if (isFlash) {
76
97
  // For flash state, only update if there's a new value
77
98
  if (val !== undefined) {
@@ -81,10 +102,15 @@ export function useLocationState<TArgs extends unknown[], TState>(
81
102
  setState(val);
82
103
  }
83
104
  } else {
84
- setState(window.history.state?.state as TState | undefined);
105
+ setState(readLocationStateValue<TState>(key));
85
106
  }
86
107
  };
87
108
 
109
+ if (initialReadDeferredRef.current) {
110
+ initialReadDeferredRef.current = false;
111
+ setState(readLocationStateValue<TState>(key));
112
+ }
113
+
88
114
  window.addEventListener("popstate", handlePopstate);
89
115
  window.addEventListener("__rsc_locationstate", handleLocationState);
90
116
  return () => {
@@ -32,27 +32,35 @@ 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
46
  // Initial state from context event controller, or empty fallback without provider.
47
- const [value, setValue] = useState<A | S>(() => {
47
+ const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
48
48
  if (!ctx) {
49
- const collected = collectHandleData(handle, {}, []);
49
+ const collected = collectHandleData(
50
+ handle,
51
+ {},
52
+ [],
53
+ ) as Rango.FlightSerialize<A>;
50
54
  return selector ? selector(collected) : collected;
51
55
  }
52
56
 
53
57
  // On client, use event controller state
54
58
  const state = ctx.eventController.getHandleState();
55
- const collected = collectHandleData(handle, state.data, state.segmentOrder);
59
+ const collected = collectHandleData(
60
+ handle,
61
+ state.data,
62
+ state.segmentOrder,
63
+ ) as Rango.FlightSerialize<A>;
56
64
  return selector ? selector(collected) : collected;
57
65
  });
58
66
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
@@ -76,7 +84,7 @@ export function useHandle<T, A, S>(
76
84
  handle,
77
85
  currentHandleState.data,
78
86
  currentHandleState.segmentOrder,
79
- );
87
+ ) as Rango.FlightSerialize<A>;
80
88
  const currentValue = selectorRef.current
81
89
  ? selectorRef.current(currentCollected)
82
90
  : currentCollected;
@@ -93,7 +101,7 @@ export function useHandle<T, A, S>(
93
101
  handle,
94
102
  state.data,
95
103
  state.segmentOrder,
96
- );
104
+ ) as Rango.FlightSerialize<A>;
97
105
  const nextValue = selectorRef.current
98
106
  ? selectorRef.current(collected)
99
107
  : collected;
@@ -53,6 +53,12 @@ export function useNavigation<T>(
53
53
  });
54
54
  const prevState = useRef(baseValue);
55
55
 
56
+ // Tracks whether the most recent setOptimisticValue call pinned the value
57
+ // to a non-idle state. Used to decide whether to emit a release update when
58
+ // returning to idle, so the optimistic store doesn't stay pinned if a
59
+ // parent transition (e.g. <Link> click) is still pending.
60
+ const optimisticPinnedRef = useRef(false);
61
+
56
62
  // useOptimistic allows immediate updates during transitions/actions
57
63
  const [value, setOptimisticValue] = useOptimistic(baseValue);
58
64
 
@@ -82,11 +88,25 @@ export function useNavigation<T>(
82
88
  const hasInflightActions =
83
89
  ctx.eventController.getInflightActions().size > 0;
84
90
 
85
- if (hasInflightActions || publicState.state !== "idle") {
86
- // Use optimistic update for immediate feedback during transitions
91
+ const shouldPin = hasInflightActions || publicState.state !== "idle";
92
+
93
+ if (shouldPin) {
94
+ // Pin the optimistic store so the loading value shows immediately
95
+ // even if a parent transition (e.g. <Link> click) defers the
96
+ // urgent setBaseValue commit.
97
+ startTransition(() => {
98
+ setOptimisticValue(nextSelected);
99
+ });
100
+ optimisticPinnedRef.current = true;
101
+ } else if (optimisticPinnedRef.current) {
102
+ // Release a previously-pinned optimistic value. Without this,
103
+ // useOptimistic keeps returning the stale loading value while
104
+ // any parent transition is still pending, even after baseValue
105
+ // flipped to idle.
87
106
  startTransition(() => {
88
107
  setOptimisticValue(nextSelected);
89
108
  });
109
+ optimisticPinnedRef.current = false;
90
110
  }
91
111
 
92
112
  // Always update base state so UI reflects current state
@@ -4,6 +4,8 @@ import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
5
  import { shallowEqual } from "./shallow-equal.js";
6
6
 
7
+ const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
8
+
7
9
  /**
8
10
  * Hook to access the current route params.
9
11
  *
@@ -16,24 +18,34 @@ import { shallowEqual } from "./shallow-equal.js";
16
18
  * const params = useParams();
17
19
  * // { productId: "123" }
18
20
  *
21
+ * // Annotate the expected shape via a generic
22
+ * const { productId } = useParams<{ productId: string }>();
23
+ *
19
24
  * // With selector
20
25
  * const productId = useParams(p => p.productId);
21
26
  * ```
22
27
  */
23
- export function useParams(): Record<string, string>;
28
+ // `T extends object` (not `Record<string, string | undefined>`) so that
29
+ // interface shapes pass the constraint — interfaces lack an implicit
30
+ // index signature and would otherwise be rejected. The generic is a
31
+ // shape annotation, not a runtime check; the body always returns the
32
+ // underlying params map unchanged. The default and selector input use
33
+ // `string | undefined` because absent optional params are omitted from
34
+ // the params record at runtime — the type must reflect that so callers
35
+ // don't write `p.locale.length` and crash when the segment is absent.
36
+ export function useParams<
37
+ T extends object = Record<string, string | undefined>,
38
+ >(): Readonly<T>;
24
39
  export function useParams<T>(
25
- selector: (params: Record<string, string>) => T,
40
+ selector: (params: Record<string, string | undefined>) => T,
26
41
  ): T;
27
42
  export function useParams<T>(
28
- selector?: (params: Record<string, string>) => T,
29
- ): T | Record<string, string> {
43
+ selector?: (params: Record<string, string | undefined>) => T,
44
+ ): T | Record<string, string | undefined> {
30
45
  const ctx = useContext(NavigationStoreContext);
31
46
 
32
47
  const [value, setValue] = useState<T | Record<string, string>>(() => {
33
- if (!ctx) {
34
- return selector ? selector({}) : {};
35
- }
36
- const params = ctx.eventController.getParams();
48
+ const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
37
49
  return selector ? selector(params) : params;
38
50
  });
39
51
 
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import type { LocalReverseFunction } from "../../reverse.js";
5
+ import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
6
+ import { serializeSearchParams } from "../../search-params.js";
7
+ import { useMount } from "./use-mount.js";
8
+ import { useParams } from "./use-params.js";
9
+
10
+ type RouteEntry = string | { readonly path: string };
11
+ type LocalRouteMap = Readonly<Record<string, RouteEntry>>;
12
+
13
+ function getPattern(entry: RouteEntry | undefined): string | undefined {
14
+ if (entry === undefined) return undefined;
15
+ return typeof entry === "string" ? entry : entry.path;
16
+ }
17
+
18
+ /**
19
+ * Join an include mount prefix with a mount-relative pattern.
20
+ *
21
+ * `pattern === "/"` is the index of the local module — under a non-root
22
+ * mount it must collapse so `/` under `/blog` becomes `/blog`, not
23
+ * `/blog/`. This matches `ctx.reverse(".index")` on the server.
24
+ */
25
+ function joinMount(mount: string, pattern: string): string {
26
+ if (pattern === "/") {
27
+ if (mount === "" || mount === "/") return "/";
28
+ return mount.endsWith("/") ? mount.slice(0, -1) : mount;
29
+ }
30
+ const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
31
+ return normalizedMount + pattern;
32
+ }
33
+
34
+ /**
35
+ * Mount-aware reverse function for a locally-imported `routes` map.
36
+ *
37
+ * The `routes` map you pass IS the scope: `reverse("name")` looks the name up
38
+ * in that map (verbatim), prefixes the result with the surrounding `include()`
39
+ * mount path via `useMount()`, and substitutes params — auto-filling from the
40
+ * current matched route's params, with explicit params overriding. A module's
41
+ * components can therefore reverse their own routes without knowing where the
42
+ * module is mounted: include it under any prefix and the URLs resolve correctly.
43
+ *
44
+ * The leading dot is optional and cosmetic: `reverse("post")` and
45
+ * `reverse(".post")` resolve identically. The dot exists only as a readability
46
+ * convention and for parity with `ctx.reverse(".name")` on the server; here the
47
+ * passed map is the scope, so there is no separate global namespace to
48
+ * disambiguate and the dot carries no meaning.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * "use client";
53
+ * import { Link, useReverse } from "@rangojs/router/client";
54
+ * import { routes as blogRoutes } from "../urls/blog.gen.js";
55
+ *
56
+ * function BlogNav() {
57
+ * const reverse = useReverse(blogRoutes);
58
+ * return (
59
+ * <>
60
+ * <Link to={reverse("index")}>Blog</Link>
61
+ * <Link to={reverse("post", { postId: "hello" })}>Post</Link>
62
+ * </>
63
+ * );
64
+ * }
65
+ * ```
66
+ */
67
+ export function useReverse<const TRoutes extends LocalRouteMap>(
68
+ routes: TRoutes,
69
+ ): LocalReverseFunction<TRoutes> {
70
+ const mount = useMount();
71
+ const currentParams = useParams();
72
+
73
+ return useCallback(
74
+ ((
75
+ name: string,
76
+ explicitParams?: Record<string, string | undefined>,
77
+ search?: Record<string, unknown>,
78
+ ): string => {
79
+ // The leading dot is optional. The passed map IS the scope, so a dot to
80
+ // signal "local" is unnecessary — "detail" and ".detail" resolve the same.
81
+ // A dot is accepted (and stripped) for readability / ctx.reverse parity.
82
+ const lookupName = name.startsWith(".") ? name.slice(1) : name;
83
+ const entry = (routes as LocalRouteMap)[lookupName];
84
+ const pattern = getPattern(entry);
85
+ if (pattern === undefined) {
86
+ throw new Error(`Unknown route: "${name}"`);
87
+ }
88
+
89
+ const joined = joinMount(mount, pattern);
90
+
91
+ const mergedParams = explicitParams
92
+ ? { ...currentParams, ...explicitParams }
93
+ : currentParams;
94
+
95
+ const substituted = substitutePatternParams(joined, mergedParams, name);
96
+
97
+ if (search) {
98
+ const qs = serializeSearchParams(search);
99
+ if (qs) return `${substituted}?${qs}`;
100
+ }
101
+
102
+ return substituted;
103
+ }) as LocalReverseFunction<TRoutes>,
104
+ [routes, mount, currentParams],
105
+ );
106
+ }
@@ -13,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
13
13
  * useRouter() do not re-render on navigation state changes.
14
14
  * For reactive navigation state, use useNavigation() instead.
15
15
  *
16
+ * Methods read `basename` from the live context on each call so that
17
+ * cross-app navigation (app-switch) sees the current app's basename
18
+ * rather than the one captured at mount time.
19
+ *
16
20
  * @example
17
21
  * ```tsx
18
22
  * const router = useRouter();
@@ -29,7 +33,10 @@ export function useRouter(): RouterInstance {
29
33
  throw new Error("useRouter must be used within NavigationProvider");
30
34
  }
31
35
 
32
- // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
36
+ // Stable reference: ctx itself is stable, and reads on each method call
37
+ // pick up live basename values from the context (backed by a live ref
38
+ // in NavigationProvider), so app-switch transitions are reflected without
39
+ // recreating this object.
33
40
  return useMemo<RouterInstance>(() => {
34
41
  /** Prefix a root-relative path with basename if not already prefixed. */
35
42
  function withBasename(url: string): string {
@@ -65,7 +72,20 @@ export function useRouter(): RouterInstance {
65
72
  },
66
73
 
67
74
  back(): void {
68
- window.history.back();
75
+ // Avoid escaping the host on the first entry of this session.
76
+ // Prefer the Navigation API; fall back to the router-stamped
77
+ // history.state.idx (set by pushHistoryWithIdx) for older browsers.
78
+ const nav = (window as { navigation?: { canGoBack: boolean } })
79
+ .navigation;
80
+ const canGoBack =
81
+ nav && typeof nav.canGoBack === "boolean"
82
+ ? nav.canGoBack
83
+ : ((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0;
84
+ if (canGoBack) {
85
+ window.history.back();
86
+ } else {
87
+ ctx.navigate(withBasename("/"), { replace: true });
88
+ }
69
89
  },
70
90
 
71
91
  forward(): void {