@rangojs/router 0.0.0-experimental.20 → 0.0.0-experimental.204030a9

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 (293) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +242 -55
  3. package/dist/bin/rango.js +277 -99
  4. package/dist/vite/index.js +2929 -1132
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +68 -21
  8. package/skills/breadcrumbs/SKILL.md +252 -0
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +159 -10
  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 +262 -51
  16. package/skills/host-router/SKILL.md +243 -0
  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 +249 -17
  21. package/skills/loader/SKILL.md +291 -31
  22. package/skills/middleware/SKILL.md +49 -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 +197 -6
  28. package/skills/prerender/SKILL.md +125 -102
  29. package/skills/rango/SKILL.md +242 -23
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +91 -8
  33. package/skills/router-setup/SKILL.md +98 -8
  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 +511 -188
  37. package/skills/typesafety/SKILL.md +354 -50
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +117 -0
  42. package/src/__internal.ts +92 -0
  43. package/src/browser/action-coordinator.ts +53 -36
  44. package/src/browser/app-shell.ts +52 -0
  45. package/src/browser/app-version.ts +14 -0
  46. package/src/browser/event-controller.ts +91 -70
  47. package/src/browser/history-state.ts +21 -0
  48. package/src/browser/index.ts +3 -3
  49. package/src/browser/link-interceptor.ts +4 -0
  50. package/src/browser/navigation-bridge.ts +183 -18
  51. package/src/browser/navigation-client.ts +187 -57
  52. package/src/browser/navigation-store.ts +75 -17
  53. package/src/browser/navigation-transaction.ts +21 -37
  54. package/src/browser/partial-update.ts +143 -40
  55. package/src/browser/prefetch/cache.ts +275 -28
  56. package/src/browser/prefetch/fetch.ts +191 -46
  57. package/src/browser/prefetch/policy.ts +6 -0
  58. package/src/browser/prefetch/queue.ts +123 -20
  59. package/src/browser/prefetch/resource-ready.ts +77 -0
  60. package/src/browser/rango-state.ts +53 -13
  61. package/src/browser/react/Link.tsx +98 -14
  62. package/src/browser/react/NavigationProvider.tsx +110 -33
  63. package/src/browser/react/context.ts +7 -2
  64. package/src/browser/react/filter-segment-order.ts +51 -7
  65. package/src/browser/react/index.ts +3 -0
  66. package/src/browser/react/location-state-shared.ts +175 -4
  67. package/src/browser/react/location-state.ts +39 -13
  68. package/src/browser/react/use-handle.ts +23 -64
  69. package/src/browser/react/use-navigation.ts +22 -2
  70. package/src/browser/react/use-params.ts +20 -8
  71. package/src/browser/react/use-reverse.ts +106 -0
  72. package/src/browser/react/use-router.ts +43 -10
  73. package/src/browser/react/use-segments.ts +11 -8
  74. package/src/browser/response-adapter.ts +25 -0
  75. package/src/browser/rsc-router.tsx +200 -75
  76. package/src/browser/scroll-restoration.ts +46 -39
  77. package/src/browser/segment-reconciler.ts +36 -9
  78. package/src/browser/segment-structure-assert.ts +2 -2
  79. package/src/browser/server-action-bridge.ts +31 -36
  80. package/src/browser/types.ts +81 -5
  81. package/src/build/collect-fallback-refs.ts +107 -0
  82. package/src/build/generate-manifest.ts +65 -40
  83. package/src/build/generate-route-types.ts +5 -0
  84. package/src/build/index.ts +2 -0
  85. package/src/build/route-trie.ts +69 -26
  86. package/src/build/route-types/codegen.ts +4 -4
  87. package/src/build/route-types/include-resolution.ts +9 -2
  88. package/src/build/route-types/per-module-writer.ts +7 -4
  89. package/src/build/route-types/router-processing.ts +278 -88
  90. package/src/build/route-types/scan-filter.ts +9 -2
  91. package/src/build/route-types/source-scan.ts +118 -0
  92. package/src/build/runtime-discovery.ts +9 -20
  93. package/src/cache/cache-runtime.ts +15 -11
  94. package/src/cache/cache-scope.ts +76 -49
  95. package/src/cache/cf/cf-cache-store.ts +501 -18
  96. package/src/cache/cf/index.ts +5 -1
  97. package/src/cache/document-cache.ts +17 -7
  98. package/src/cache/index.ts +1 -0
  99. package/src/cache/taint.ts +55 -0
  100. package/src/client.rsc.tsx +5 -1
  101. package/src/client.tsx +95 -284
  102. package/src/context-var.ts +72 -2
  103. package/src/debug.ts +2 -2
  104. package/src/decode-loader-results.ts +36 -0
  105. package/src/errors.ts +30 -1
  106. package/src/handle.ts +65 -12
  107. package/src/handles/breadcrumbs.ts +66 -0
  108. package/src/handles/index.ts +1 -0
  109. package/src/host/index.ts +2 -5
  110. package/src/host/router.ts +129 -57
  111. package/src/host/types.ts +31 -2
  112. package/src/host/utils.ts +1 -1
  113. package/src/href-client.ts +140 -20
  114. package/src/index.rsc.ts +15 -40
  115. package/src/index.ts +92 -76
  116. package/src/loader-store.ts +500 -0
  117. package/src/loader.rsc.ts +2 -5
  118. package/src/loader.ts +3 -10
  119. package/src/missing-id-error.ts +68 -0
  120. package/src/outlet-context.ts +1 -1
  121. package/src/prerender/store.ts +57 -15
  122. package/src/prerender.ts +141 -80
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -15
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +435 -260
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +110 -34
  129. package/src/route-definition/index.ts +3 -3
  130. package/src/route-definition/redirect.ts +11 -3
  131. package/src/route-definition/resolve-handler-use.ts +155 -0
  132. package/src/route-definition/use-item-types.ts +32 -0
  133. package/src/route-map-builder.ts +7 -1
  134. package/src/route-types.ts +37 -41
  135. package/src/router/basename.ts +14 -0
  136. package/src/router/content-negotiation.ts +113 -1
  137. package/src/router/error-handling.ts +1 -1
  138. package/src/router/find-match.ts +4 -2
  139. package/src/router/handler-context.ts +105 -39
  140. package/src/router/intercept-resolution.ts +15 -22
  141. package/src/router/lazy-includes.ts +12 -9
  142. package/src/router/loader-resolution.ts +175 -23
  143. package/src/router/logging.ts +5 -2
  144. package/src/router/manifest.ts +31 -16
  145. package/src/router/match-api.ts +129 -193
  146. package/src/router/match-handlers.ts +63 -20
  147. package/src/router/match-middleware/background-revalidation.ts +30 -2
  148. package/src/router/match-middleware/cache-lookup.ts +136 -106
  149. package/src/router/match-middleware/cache-store.ts +54 -10
  150. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  151. package/src/router/match-middleware/segment-resolution.ts +61 -5
  152. package/src/router/match-result.ts +124 -18
  153. package/src/router/metrics.ts +239 -14
  154. package/src/router/middleware-types.ts +61 -31
  155. package/src/router/middleware.ts +226 -124
  156. package/src/router/navigation-snapshot.ts +182 -0
  157. package/src/router/pattern-matching.ts +118 -19
  158. package/src/router/prerender-match.ts +114 -10
  159. package/src/router/preview-match.ts +32 -102
  160. package/src/router/request-classification.ts +286 -0
  161. package/src/router/revalidation.ts +85 -9
  162. package/src/router/route-snapshot.ts +245 -0
  163. package/src/router/router-context.ts +6 -1
  164. package/src/router/router-interfaces.ts +91 -29
  165. package/src/router/router-options.ts +89 -19
  166. package/src/router/router-registry.ts +2 -5
  167. package/src/router/segment-resolution/fresh.ts +240 -23
  168. package/src/router/segment-resolution/helpers.ts +30 -25
  169. package/src/router/segment-resolution/loader-cache.ts +1 -0
  170. package/src/router/segment-resolution/revalidation.ts +483 -289
  171. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  172. package/src/router/segment-wrappers.ts +2 -0
  173. package/src/router/substitute-pattern-params.ts +56 -0
  174. package/src/router/telemetry.ts +99 -0
  175. package/src/router/trie-matching.ts +38 -15
  176. package/src/router/types.ts +9 -0
  177. package/src/router/url-params.ts +49 -0
  178. package/src/router.ts +120 -32
  179. package/src/rsc/handler-context.ts +2 -2
  180. package/src/rsc/handler.ts +524 -370
  181. package/src/rsc/helpers.ts +91 -43
  182. package/src/rsc/index.ts +1 -21
  183. package/src/rsc/loader-fetch.ts +23 -3
  184. package/src/rsc/manifest-init.ts +5 -1
  185. package/src/rsc/origin-guard.ts +28 -10
  186. package/src/rsc/progressive-enhancement.ts +39 -10
  187. package/src/rsc/response-route-handler.ts +46 -53
  188. package/src/rsc/rsc-rendering.ts +69 -89
  189. package/src/rsc/runtime-warnings.ts +9 -10
  190. package/src/rsc/server-action.ts +39 -47
  191. package/src/rsc/ssr-setup.ts +144 -0
  192. package/src/rsc/types.ts +19 -3
  193. package/src/search-params.ts +20 -17
  194. package/src/segment-content-promise.ts +67 -0
  195. package/src/segment-loader-promise.ts +122 -0
  196. package/src/segment-system.tsx +219 -67
  197. package/src/serialize.ts +243 -0
  198. package/src/server/context.ts +285 -63
  199. package/src/server/cookie-store.ts +28 -4
  200. package/src/server/handle-store.ts +19 -0
  201. package/src/server/loader-registry.ts +9 -8
  202. package/src/server/request-context.ts +228 -65
  203. package/src/server.ts +6 -0
  204. package/src/ssr/index.tsx +9 -1
  205. package/src/static-handler.ts +19 -7
  206. package/src/testing/cache-status.ts +166 -0
  207. package/src/testing/collect-handle.ts +63 -0
  208. package/src/testing/dispatch.ts +440 -0
  209. package/src/testing/dom.entry.ts +22 -0
  210. package/src/testing/e2e/fixture.ts +154 -0
  211. package/src/testing/e2e/index.ts +149 -0
  212. package/src/testing/e2e/matchers.ts +51 -0
  213. package/src/testing/e2e/page-helpers.ts +272 -0
  214. package/src/testing/e2e/parity.ts +306 -0
  215. package/src/testing/e2e/server.ts +183 -0
  216. package/src/testing/flight-matchers.ts +104 -0
  217. package/src/testing/flight-runtime.d.ts +21 -0
  218. package/src/testing/flight.entry.ts +22 -0
  219. package/src/testing/flight.ts +182 -0
  220. package/src/testing/generated-routes.ts +223 -0
  221. package/src/testing/index.ts +98 -0
  222. package/src/testing/internal/context.ts +151 -0
  223. package/src/testing/render-route.tsx +536 -0
  224. package/src/testing/run-loader.ts +296 -0
  225. package/src/testing/run-middleware.ts +170 -0
  226. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  227. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  228. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  229. package/src/testing/vitest-stubs/version.ts +5 -0
  230. package/src/testing/vitest.ts +112 -0
  231. package/src/theme/index.ts +4 -13
  232. package/src/types/cache-types.ts +4 -4
  233. package/src/types/global-namespace.ts +39 -26
  234. package/src/types/handler-context.ts +197 -79
  235. package/src/types/index.ts +1 -0
  236. package/src/types/loader-types.ts +41 -15
  237. package/src/types/request-scope.ts +126 -0
  238. package/src/types/route-config.ts +17 -8
  239. package/src/types/route-entry.ts +19 -1
  240. package/src/types/segments.ts +37 -6
  241. package/src/urls/include-helper.ts +34 -67
  242. package/src/urls/index.ts +0 -3
  243. package/src/urls/path-helper-types.ts +50 -9
  244. package/src/urls/path-helper.ts +63 -63
  245. package/src/urls/pattern-types.ts +48 -19
  246. package/src/urls/response-types.ts +25 -22
  247. package/src/urls/type-extraction.ts +26 -116
  248. package/src/urls/urls-function.ts +1 -5
  249. package/src/use-loader.tsx +487 -44
  250. package/src/vite/debug.ts +185 -0
  251. package/src/vite/discovery/bundle-postprocess.ts +63 -91
  252. package/src/vite/discovery/discover-routers.ts +106 -53
  253. package/src/vite/discovery/discovery-errors.ts +194 -0
  254. package/src/vite/discovery/gate-state.ts +171 -0
  255. package/src/vite/discovery/prerender-collection.ts +222 -107
  256. package/src/vite/discovery/route-types-writer.ts +40 -84
  257. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  258. package/src/vite/discovery/state.ts +50 -13
  259. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  260. package/src/vite/index.ts +10 -3
  261. package/src/vite/plugin-types.ts +111 -72
  262. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  263. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  264. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  265. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  266. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  267. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  268. package/src/vite/plugins/expose-action-id.ts +55 -33
  269. package/src/vite/plugins/expose-id-utils.ts +24 -8
  270. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  271. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  272. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  273. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  274. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  275. package/src/vite/plugins/performance-tracks.ts +92 -0
  276. package/src/vite/plugins/refresh-cmd.ts +127 -0
  277. package/src/vite/plugins/use-cache-transform.ts +65 -50
  278. package/src/vite/plugins/version-injector.ts +39 -23
  279. package/src/vite/plugins/version-plugin.ts +72 -3
  280. package/src/vite/plugins/virtual-entries.ts +2 -2
  281. package/src/vite/rango.ts +265 -226
  282. package/src/vite/router-discovery.ts +924 -137
  283. package/src/vite/utils/ast-handler-extract.ts +15 -15
  284. package/src/vite/utils/banner.ts +4 -4
  285. package/src/vite/utils/bundle-analysis.ts +4 -2
  286. package/src/vite/utils/client-chunks.ts +190 -0
  287. package/src/vite/utils/forward-user-plugins.ts +193 -0
  288. package/src/vite/utils/manifest-utils.ts +21 -5
  289. package/src/vite/utils/package-resolution.ts +41 -1
  290. package/src/vite/utils/prerender-utils.ts +98 -5
  291. package/src/vite/utils/shared-utils.ts +109 -27
  292. package/src/browser/action-response-classifier.ts +0 -99
  293. package/src/route-definition/route-function.ts +0 -119
@@ -3,8 +3,10 @@
3
3
  import React, {
4
4
  useState,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useCallback,
7
8
  useMemo,
9
+ useRef,
8
10
  use,
9
11
  type ReactNode,
10
12
  } from "react";
@@ -25,6 +27,8 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
25
27
  import { NonceContext } from "./nonce-context.js";
26
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
27
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
+ import { handleNavigationEnd } from "../scroll-restoration.js";
31
+ import { createAppShellRef, type AppShellRef } from "../app-shell.js";
28
32
 
29
33
  /**
30
34
  * Process handles from an async generator, updating the event controller
@@ -43,10 +47,22 @@ async function processHandles(
43
47
  store: NavigationStore;
44
48
  matched?: string[];
45
49
  isPartial?: boolean;
50
+ /** Server's `resolvedIds`: every segment re-resolved this request,
51
+ * including null-component ones excluded from `diff`/`segments`.
52
+ * Drives cleanup of stale handle buckets when a re-resolved segment
53
+ * pushed nothing. */
54
+ resolvedIds?: string[];
46
55
  historyKey: string;
47
56
  },
48
57
  ): Promise<void> {
49
- const { eventController, store, matched, isPartial, historyKey } = opts;
58
+ const {
59
+ eventController,
60
+ store,
61
+ matched,
62
+ isPartial,
63
+ resolvedIds,
64
+ historyKey,
65
+ } = opts;
50
66
 
51
67
  let yieldCount = 0;
52
68
  for await (const handleData of handlesGenerator) {
@@ -61,7 +77,7 @@ async function processHandles(
61
77
  }
62
78
 
63
79
  yieldCount++;
64
- eventController.setHandleData(handleData, matched, isPartial);
80
+ eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
65
81
  }
66
82
 
67
83
  // Check again before final updates
@@ -69,12 +85,11 @@ async function processHandles(
69
85
  return;
70
86
  }
71
87
 
72
- // For partial updates where the generator yielded nothing (cached handlers),
73
- // we still need to update the segment order to clean up stale handle data.
74
- // This happens when navigating away from a route - the handlers for the new
75
- // route might not push any breadcrumbs, but we still need to remove the old ones.
88
+ // For partial updates where the generator yielded nothing (every
89
+ // re-resolved handler pushed nothing), still call setHandleData so the
90
+ // cleanup pass can clear out stale buckets for those segments.
76
91
  if (yieldCount === 0 && matched) {
77
- eventController.setHandleData({}, matched, true);
92
+ eventController.setHandleData({}, matched, true, resolvedIds);
78
93
  }
79
94
 
80
95
  // After handles processing completes, update the cache's handleData.
@@ -130,10 +145,23 @@ export interface NavigationProviderProps {
130
145
  warmupEnabled?: boolean;
131
146
 
132
147
  /**
133
- * App version from server payload (stable, immutable).
134
- * Forwarded to prefetch requests for version mismatch detection.
148
+ * App version from server payload.
149
+ * Used only as a fallback when `appShellRef` is not supplied.
135
150
  */
136
151
  version?: string;
152
+
153
+ /**
154
+ * URL prefix for all routes (from createRouter({ basename })).
155
+ * Used only as a fallback when `appShellRef` is not supplied.
156
+ */
157
+ basename?: string;
158
+
159
+ /**
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.
163
+ */
164
+ appShellRef?: AppShellRef;
137
165
  }
138
166
 
139
167
  /**
@@ -166,6 +194,8 @@ export function NavigationProvider({
166
194
  initialTheme,
167
195
  warmupEnabled,
168
196
  version,
197
+ basename,
198
+ appShellRef,
169
199
  }: NavigationProviderProps): ReactNode {
170
200
  // Track current payload for rendering (this triggers re-renders)
171
201
  const [payload, setPayload] = useState(initialPayload);
@@ -187,17 +217,34 @@ export function NavigationProvider({
187
217
  await bridge.refresh();
188
218
  }, []);
189
219
 
190
- // Context value is stable (store, eventController, navigate, refresh never change)
191
- const contextValue = useMemo<NavigationStoreContextValue>(
192
- () => ({
220
+ // 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.
223
+ const fallbackShellRef = useRef<AppShellRef | null>(null);
224
+ if (!fallbackShellRef.current) {
225
+ fallbackShellRef.current = createAppShellRef({ basename, version });
226
+ }
227
+ const shellRef = appShellRef ?? fallbackShellRef.current;
228
+
229
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
230
+ const value = {
193
231
  store,
194
232
  eventController,
195
233
  navigate,
196
234
  refresh,
197
- version,
198
- }),
199
- [],
200
- );
235
+ } as NavigationStoreContextValue;
236
+ Object.defineProperty(value, "basename", {
237
+ configurable: true,
238
+ enumerable: true,
239
+ get: () => shellRef.get().basename,
240
+ });
241
+ Object.defineProperty(value, "version", {
242
+ configurable: true,
243
+ enumerable: true,
244
+ get: () => shellRef.get().version,
245
+ });
246
+ return value;
247
+ }, []);
201
248
 
202
249
  // Connection warmup: keep TLS alive after idle periods.
203
250
  // After 60s of no user interaction, marks connection as "cold".
@@ -286,31 +333,61 @@ export function NavigationProvider({
286
333
  };
287
334
  }, [warmupEnabled]);
288
335
 
289
- // Cancel speculative prefetches when navigation starts.
290
- // Viewport/render prefetches should not compete with navigation fetches.
336
+ // Cancel non-matching prefetches when navigation starts.
337
+ // Frees connections so the navigation fetch isn't competing with
338
+ // speculative prefetches. The prefetch matching the navigation target
339
+ // is kept alive so it can be reused via consumeInflightPrefetch.
291
340
  useEffect(() => {
292
341
  let wasIdle = true;
293
342
  const unsub = eventController.subscribe(() => {
294
343
  const state = eventController.getState();
295
344
  const isIdle = state.state === "idle" && !state.isStreaming;
296
345
  if (wasIdle && !isIdle) {
297
- cancelAllPrefetches();
346
+ cancelAllPrefetches(state.pendingUrl);
298
347
  }
299
348
  wasIdle = isIdle;
300
349
  });
301
350
  return unsub;
302
351
  }, [eventController]);
303
352
 
353
+ // Pending scroll action to apply after React commits
354
+ const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
355
+
356
+ // Apply scroll after React commits the new content to the DOM
357
+ useLayoutEffect(() => {
358
+ const scrollAction = pendingScrollRef.current;
359
+ if (!scrollAction) return;
360
+ pendingScrollRef.current = undefined;
361
+
362
+ if (scrollAction.enabled === false) return;
363
+
364
+ handleNavigationEnd({
365
+ restore: scrollAction.restore,
366
+ scroll: scrollAction.enabled,
367
+ isStreaming: scrollAction.isStreaming,
368
+ });
369
+ });
370
+
304
371
  // Subscribe to UI updates (for re-rendering the tree)
305
372
  useEffect(() => {
306
373
  const unsubscribe = store.onUpdate((update) => {
374
+ // Capture scroll intent — it will be applied in useLayoutEffect
375
+ // after React commits this state update to the DOM.
376
+ // Always assign (even undefined) to clear stale scroll from prior navigations,
377
+ // so server actions or error updates don't accidentally replay old scroll.
378
+ pendingScrollRef.current = update.scroll;
379
+
307
380
  setPayload({
308
381
  root: update.root,
309
382
  metadata: update.metadata,
310
383
  });
311
384
 
312
- // Update route params
313
- eventController.setParams(update.metadata.params ?? {});
385
+ // Update route params. Only reset when the server actually sends a params
386
+ // map — an absent `params` field means "no change" (e.g., legacy action
387
+ // responses that omitted params). Explicit `{}` still clears correctly.
388
+ if (update.metadata.params !== undefined) {
389
+ eventController.setParams(update.metadata.params);
390
+ }
314
391
 
315
392
  // Update handle data progressively as it streams in
316
393
  if (update.metadata.handles) {
@@ -323,24 +400,20 @@ export function NavigationProvider({
323
400
  store,
324
401
  matched: update.metadata.matched,
325
402
  isPartial: update.metadata.isPartial,
403
+ resolvedIds: update.metadata.resolvedIds,
326
404
  historyKey,
327
405
  }).catch((err) =>
328
406
  console.error("[NavigationProvider] Error consuming handles:", err),
329
407
  );
330
- } else if (update.metadata.cachedHandleData) {
331
- // For back/forward navigation from cache, restore the cached handleData
332
- // This restores breadcrumbs to the exact state they were when the page was cached
333
- eventController.setHandleData(
334
- update.metadata.cachedHandleData,
335
- update.metadata.matched,
336
- false, // full replace - restore entire cached state
337
- );
338
408
  } else if (update.metadata.matched) {
339
- // For cached navigations without handleData, update segmentOrder to clean up stale data
409
+ // cachedHandleData present -> full restore (back/forward); absent ->
410
+ // partial cleanup of segments no longer matched.
411
+ const cached = update.metadata.cachedHandleData;
340
412
  eventController.setHandleData(
341
- {}, // Empty data - all existing data not in matched will be cleaned up
413
+ cached ?? {},
342
414
  update.metadata.matched,
343
- true, // partial update - will clean up segments not in matched
415
+ cached === undefined,
416
+ cached === undefined ? update.metadata.resolvedIds : undefined,
344
417
  );
345
418
  }
346
419
  });
@@ -362,7 +435,11 @@ export function NavigationProvider({
362
435
  // Build the content tree
363
436
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
364
437
 
365
- // Wrap with ThemeProvider when theme is enabled
438
+ // 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.
366
443
  if (themeConfig) {
367
444
  content = (
368
445
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
@@ -43,10 +43,15 @@ export interface NavigationStoreContextValue {
43
43
  refresh: () => Promise<void>;
44
44
 
45
45
  /**
46
- * App version from server payload (stable, immutable).
47
- * Used in prefetch requests for version mismatch detection.
46
+ * App version from the initial server payload.
48
47
  */
49
48
  version: string | undefined;
49
+
50
+ /**
51
+ * URL prefix for all routes (from createRouter({ basename })).
52
+ * Used by Link and useRouter() to auto-prefix app-local paths.
53
+ */
54
+ basename: string | undefined;
50
55
  }
51
56
 
52
57
  /**
@@ -1,11 +1,55 @@
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
+ // 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
+ for (const [parent, slots] of slotsByParent) {
52
+ if (!nonSlotSet.has(parent)) result.push(...slots);
53
+ }
54
+ return result;
11
55
  }
@@ -20,6 +20,9 @@ export { useSegments, type SegmentsState } from "./use-segments.js";
20
20
  // Handle data hook
21
21
  export { useHandle } from "./use-handle.js";
22
22
 
23
+ // Mount-aware reverse hook
24
+ export { useReverse } from "./use-reverse.js";
25
+
23
26
  // Client cache controls hook
24
27
  export {
25
28
  useClientCache,
@@ -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 () => {