@rangojs/router 0.0.0-experimental.5 → 0.0.0-experimental.50

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 (301) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +884 -4
  3. package/dist/bin/rango.js +1606 -0
  4. package/dist/vite/index.js +4567 -769
  5. package/package.json +77 -58
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +262 -0
  8. package/skills/caching/SKILL.md +85 -23
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +12 -8
  11. package/skills/document-cache/SKILL.md +18 -16
  12. package/skills/fonts/SKILL.md +167 -0
  13. package/skills/hooks/SKILL.md +334 -72
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +131 -8
  16. package/skills/layout/SKILL.md +100 -3
  17. package/skills/links/SKILL.md +89 -30
  18. package/skills/loader/SKILL.md +388 -38
  19. package/skills/middleware/SKILL.md +171 -34
  20. package/skills/mime-routes/SKILL.md +128 -0
  21. package/skills/parallel/SKILL.md +204 -1
  22. package/skills/prerender/SKILL.md +643 -0
  23. package/skills/rango/SKILL.md +85 -16
  24. package/skills/response-routes/SKILL.md +411 -0
  25. package/skills/route/SKILL.md +226 -14
  26. package/skills/router-setup/SKILL.md +123 -30
  27. package/skills/tailwind/SKILL.md +129 -0
  28. package/skills/theme/SKILL.md +9 -8
  29. package/skills/typesafety/SKILL.md +318 -89
  30. package/skills/use-cache/SKILL.md +324 -0
  31. package/src/__internal.ts +102 -4
  32. package/src/bin/rango.ts +321 -0
  33. package/src/browser/action-coordinator.ts +97 -0
  34. package/src/browser/action-response-classifier.ts +99 -0
  35. package/src/browser/event-controller.ts +92 -64
  36. package/src/browser/history-state.ts +80 -0
  37. package/src/browser/intercept-utils.ts +52 -0
  38. package/src/browser/link-interceptor.ts +24 -4
  39. package/src/browser/logging.ts +55 -0
  40. package/src/browser/merge-segment-loaders.ts +20 -12
  41. package/src/browser/navigation-bridge.ts +282 -557
  42. package/src/browser/navigation-client.ts +157 -71
  43. package/src/browser/navigation-store.ts +33 -50
  44. package/src/browser/navigation-transaction.ts +297 -0
  45. package/src/browser/network-error-handler.ts +61 -0
  46. package/src/browser/partial-update.ts +303 -310
  47. package/src/browser/prefetch/cache.ts +206 -0
  48. package/src/browser/prefetch/fetch.ts +144 -0
  49. package/src/browser/prefetch/observer.ts +65 -0
  50. package/src/browser/prefetch/policy.ts +48 -0
  51. package/src/browser/prefetch/queue.ts +128 -0
  52. package/src/browser/rango-state.ts +112 -0
  53. package/src/browser/react/Link.tsx +193 -73
  54. package/src/browser/react/NavigationProvider.tsx +160 -13
  55. package/src/browser/react/context.ts +6 -0
  56. package/src/browser/react/filter-segment-order.ts +11 -0
  57. package/src/browser/react/index.ts +12 -12
  58. package/src/browser/react/location-state-shared.ts +95 -53
  59. package/src/browser/react/location-state.ts +60 -15
  60. package/src/browser/react/mount-context.ts +24 -1
  61. package/src/browser/react/nonce-context.ts +23 -0
  62. package/src/browser/react/shallow-equal.ts +27 -0
  63. package/src/browser/react/use-action.ts +29 -51
  64. package/src/browser/react/use-client-cache.ts +5 -3
  65. package/src/browser/react/use-handle.ts +32 -79
  66. package/src/browser/react/use-href.tsx +2 -2
  67. package/src/browser/react/use-link-status.ts +6 -5
  68. package/src/browser/react/use-navigation.ts +22 -63
  69. package/src/browser/react/use-params.ts +65 -0
  70. package/src/browser/react/use-pathname.ts +47 -0
  71. package/src/browser/react/use-router.ts +63 -0
  72. package/src/browser/react/use-search-params.ts +56 -0
  73. package/src/browser/react/use-segments.ts +80 -97
  74. package/src/browser/response-adapter.ts +73 -0
  75. package/src/browser/rsc-router.tsx +188 -55
  76. package/src/browser/scroll-restoration.ts +117 -44
  77. package/src/browser/segment-reconciler.ts +221 -0
  78. package/src/browser/segment-structure-assert.ts +16 -0
  79. package/src/browser/server-action-bridge.ts +504 -599
  80. package/src/browser/shallow.ts +6 -1
  81. package/src/browser/types.ts +118 -47
  82. package/src/browser/validate-redirect-origin.ts +29 -0
  83. package/src/build/generate-manifest.ts +235 -24
  84. package/src/build/generate-route-types.ts +36 -0
  85. package/src/build/index.ts +13 -0
  86. package/src/build/route-trie.ts +265 -0
  87. package/src/build/route-types/ast-helpers.ts +25 -0
  88. package/src/build/route-types/ast-route-extraction.ts +98 -0
  89. package/src/build/route-types/codegen.ts +102 -0
  90. package/src/build/route-types/include-resolution.ts +411 -0
  91. package/src/build/route-types/param-extraction.ts +48 -0
  92. package/src/build/route-types/per-module-writer.ts +128 -0
  93. package/src/build/route-types/router-processing.ts +479 -0
  94. package/src/build/route-types/scan-filter.ts +78 -0
  95. package/src/build/runtime-discovery.ts +231 -0
  96. package/src/cache/background-task.ts +34 -0
  97. package/src/cache/cache-key-utils.ts +44 -0
  98. package/src/cache/cache-policy.ts +125 -0
  99. package/src/cache/cache-runtime.ts +342 -0
  100. package/src/cache/cache-scope.ts +167 -309
  101. package/src/cache/cf/cf-cache-store.ts +571 -17
  102. package/src/cache/cf/index.ts +13 -3
  103. package/src/cache/document-cache.ts +116 -77
  104. package/src/cache/handle-capture.ts +81 -0
  105. package/src/cache/handle-snapshot.ts +41 -0
  106. package/src/cache/index.ts +1 -15
  107. package/src/cache/memory-segment-store.ts +191 -13
  108. package/src/cache/profile-registry.ts +73 -0
  109. package/src/cache/read-through-swr.ts +134 -0
  110. package/src/cache/segment-codec.ts +256 -0
  111. package/src/cache/taint.ts +98 -0
  112. package/src/cache/types.ts +72 -122
  113. package/src/client.rsc.tsx +3 -1
  114. package/src/client.tsx +106 -126
  115. package/src/component-utils.ts +4 -4
  116. package/src/components/DefaultDocument.tsx +5 -1
  117. package/src/context-var.ts +86 -0
  118. package/src/debug.ts +19 -9
  119. package/src/errors.ts +108 -2
  120. package/src/handle.ts +15 -29
  121. package/src/handles/MetaTags.tsx +73 -20
  122. package/src/handles/breadcrumbs.ts +66 -0
  123. package/src/handles/index.ts +1 -0
  124. package/src/handles/meta.ts +30 -13
  125. package/src/host/cookie-handler.ts +165 -0
  126. package/src/host/errors.ts +97 -0
  127. package/src/host/index.ts +53 -0
  128. package/src/host/pattern-matcher.ts +214 -0
  129. package/src/host/router.ts +352 -0
  130. package/src/host/testing.ts +79 -0
  131. package/src/host/types.ts +146 -0
  132. package/src/host/utils.ts +25 -0
  133. package/src/href-client.ts +119 -29
  134. package/src/index.rsc.ts +153 -19
  135. package/src/index.ts +211 -30
  136. package/src/internal-debug.ts +11 -0
  137. package/src/loader.rsc.ts +26 -147
  138. package/src/loader.ts +27 -10
  139. package/src/network-error-thrower.tsx +3 -1
  140. package/src/outlet-provider.tsx +45 -0
  141. package/src/prerender/param-hash.ts +37 -0
  142. package/src/prerender/store.ts +185 -0
  143. package/src/prerender.ts +463 -0
  144. package/src/reverse.ts +330 -0
  145. package/src/root-error-boundary.tsx +41 -29
  146. package/src/route-content-wrapper.tsx +7 -4
  147. package/src/route-definition/dsl-helpers.ts +959 -0
  148. package/src/route-definition/helper-factories.ts +200 -0
  149. package/src/route-definition/helpers-types.ts +430 -0
  150. package/src/route-definition/index.ts +52 -0
  151. package/src/route-definition/redirect.ts +93 -0
  152. package/src/route-definition.ts +1 -1428
  153. package/src/route-map-builder.ts +217 -123
  154. package/src/route-name.ts +53 -0
  155. package/src/route-types.ts +59 -8
  156. package/src/router/content-negotiation.ts +116 -0
  157. package/src/router/debug-manifest.ts +72 -0
  158. package/src/router/error-handling.ts +9 -9
  159. package/src/router/find-match.ts +160 -0
  160. package/src/router/handler-context.ts +374 -81
  161. package/src/router/intercept-resolution.ts +397 -0
  162. package/src/router/lazy-includes.ts +237 -0
  163. package/src/router/loader-resolution.ts +215 -122
  164. package/src/router/logging.ts +251 -0
  165. package/src/router/manifest.ts +154 -35
  166. package/src/router/match-api.ts +620 -0
  167. package/src/router/match-context.ts +5 -3
  168. package/src/router/match-handlers.ts +440 -0
  169. package/src/router/match-middleware/background-revalidation.ts +108 -93
  170. package/src/router/match-middleware/cache-lookup.ts +440 -10
  171. package/src/router/match-middleware/cache-store.ts +98 -26
  172. package/src/router/match-middleware/intercept-resolution.ts +57 -17
  173. package/src/router/match-middleware/segment-resolution.ts +27 -6
  174. package/src/router/match-pipelines.ts +10 -45
  175. package/src/router/match-result.ts +55 -33
  176. package/src/router/metrics.ts +240 -15
  177. package/src/router/middleware-cookies.ts +55 -0
  178. package/src/router/middleware-types.ts +222 -0
  179. package/src/router/middleware.ts +327 -369
  180. package/src/router/pattern-matching.ts +211 -43
  181. package/src/router/prerender-match.ts +402 -0
  182. package/src/router/preview-match.ts +170 -0
  183. package/src/router/revalidation.ts +137 -38
  184. package/src/router/router-context.ts +41 -21
  185. package/src/router/router-interfaces.ts +452 -0
  186. package/src/router/router-options.ts +592 -0
  187. package/src/router/router-registry.ts +24 -0
  188. package/src/router/segment-resolution/fresh.ts +677 -0
  189. package/src/router/segment-resolution/helpers.ts +263 -0
  190. package/src/router/segment-resolution/loader-cache.ts +199 -0
  191. package/src/router/segment-resolution/revalidation.ts +1296 -0
  192. package/src/router/segment-resolution/static-store.ts +67 -0
  193. package/src/router/segment-resolution.ts +21 -0
  194. package/src/router/segment-wrappers.ts +291 -0
  195. package/src/router/telemetry-otel.ts +299 -0
  196. package/src/router/telemetry.ts +300 -0
  197. package/src/router/timeout.ts +148 -0
  198. package/src/router/trie-matching.ts +239 -0
  199. package/src/router/types.ts +77 -3
  200. package/src/router.ts +665 -4182
  201. package/src/rsc/handler-context.ts +45 -0
  202. package/src/rsc/handler.ts +764 -754
  203. package/src/rsc/helpers.ts +140 -6
  204. package/src/rsc/index.ts +0 -20
  205. package/src/rsc/loader-fetch.ts +209 -0
  206. package/src/rsc/manifest-init.ts +86 -0
  207. package/src/rsc/nonce.ts +14 -0
  208. package/src/rsc/origin-guard.ts +141 -0
  209. package/src/rsc/progressive-enhancement.ts +379 -0
  210. package/src/rsc/response-error.ts +37 -0
  211. package/src/rsc/response-route-handler.ts +347 -0
  212. package/src/rsc/rsc-rendering.ts +237 -0
  213. package/src/rsc/runtime-warnings.ts +42 -0
  214. package/src/rsc/server-action.ts +348 -0
  215. package/src/rsc/ssr-setup.ts +128 -0
  216. package/src/rsc/types.ts +38 -11
  217. package/src/search-params.ts +230 -0
  218. package/src/segment-system.tsx +172 -21
  219. package/src/server/context.ts +266 -58
  220. package/src/server/cookie-store.ts +190 -0
  221. package/src/server/fetchable-loader-store.ts +37 -0
  222. package/src/server/handle-store.ts +94 -15
  223. package/src/server/loader-registry.ts +15 -56
  224. package/src/server/request-context.ts +439 -73
  225. package/src/server.ts +35 -128
  226. package/src/ssr/index.tsx +101 -31
  227. package/src/static-handler.ts +114 -0
  228. package/src/theme/ThemeProvider.tsx +21 -15
  229. package/src/theme/ThemeScript.tsx +5 -5
  230. package/src/theme/constants.ts +5 -2
  231. package/src/theme/index.ts +4 -14
  232. package/src/theme/theme-context.ts +4 -30
  233. package/src/theme/theme-script.ts +21 -18
  234. package/src/types/boundaries.ts +158 -0
  235. package/src/types/cache-types.ts +198 -0
  236. package/src/types/error-types.ts +192 -0
  237. package/src/types/global-namespace.ts +100 -0
  238. package/src/types/handler-context.ts +773 -0
  239. package/src/types/index.ts +88 -0
  240. package/src/types/loader-types.ts +183 -0
  241. package/src/types/route-config.ts +170 -0
  242. package/src/types/route-entry.ts +109 -0
  243. package/src/types/segments.ts +150 -0
  244. package/src/types.ts +1 -1623
  245. package/src/urls/include-helper.ts +197 -0
  246. package/src/urls/index.ts +53 -0
  247. package/src/urls/path-helper-types.ts +339 -0
  248. package/src/urls/path-helper.ts +329 -0
  249. package/src/urls/pattern-types.ts +95 -0
  250. package/src/urls/response-types.ts +106 -0
  251. package/src/urls/type-extraction.ts +372 -0
  252. package/src/urls/urls-function.ts +98 -0
  253. package/src/urls.ts +1 -802
  254. package/src/use-loader.tsx +85 -77
  255. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  256. package/src/vite/discovery/discover-routers.ts +344 -0
  257. package/src/vite/discovery/prerender-collection.ts +385 -0
  258. package/src/vite/discovery/route-types-writer.ts +258 -0
  259. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  260. package/src/vite/discovery/state.ts +108 -0
  261. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  262. package/src/vite/index.ts +11 -782
  263. package/src/vite/plugin-types.ts +48 -0
  264. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  265. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  266. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  267. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -53
  268. package/src/vite/plugins/expose-id-utils.ts +287 -0
  269. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  270. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  271. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  272. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  273. package/src/vite/plugins/expose-ids/types.ts +45 -0
  274. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  275. package/src/vite/plugins/refresh-cmd.ts +65 -0
  276. package/src/vite/plugins/use-cache-transform.ts +323 -0
  277. package/src/vite/plugins/version-injector.ts +83 -0
  278. package/src/vite/plugins/version-plugin.ts +266 -0
  279. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +27 -16
  280. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  281. package/src/vite/rango.ts +445 -0
  282. package/src/vite/router-discovery.ts +777 -0
  283. package/src/vite/utils/ast-handler-extract.ts +517 -0
  284. package/src/vite/utils/banner.ts +36 -0
  285. package/src/vite/utils/bundle-analysis.ts +137 -0
  286. package/src/vite/utils/manifest-utils.ts +70 -0
  287. package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
  288. package/src/vite/utils/prerender-utils.ts +189 -0
  289. package/src/vite/utils/shared-utils.ts +169 -0
  290. package/CLAUDE.md +0 -43
  291. package/src/browser/lru-cache.ts +0 -69
  292. package/src/browser/request-controller.ts +0 -164
  293. package/src/cache/memory-store.ts +0 -253
  294. package/src/href-context.ts +0 -33
  295. package/src/href.ts +0 -255
  296. package/src/server/route-manifest-cache.ts +0 -173
  297. package/src/vite/expose-handle-id.ts +0 -209
  298. package/src/vite/expose-loader-id.ts +0 -426
  299. package/src/vite/expose-location-state-id.ts +0 -177
  300. package/src/warmup/connection-warmup.tsx +0 -94
  301. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
+ import { shallowEqual } from "./shallow-equal.js";
5
6
 
6
7
  /**
7
8
  * Segments state returned by useSegments hook
@@ -15,65 +16,6 @@ export interface SegmentsState {
15
16
  location: URL;
16
17
  }
17
18
 
18
- /**
19
- * SSR module-level state.
20
- * Populated by initSegmentsSync before React renders.
21
- * Used by useState initializer during SSR.
22
- */
23
- let ssrSegmentOrder: string[] = [];
24
- let ssrPathname: string = "/";
25
-
26
- /**
27
- * Filter segment IDs to only include routes and layouts.
28
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
29
- */
30
- function filterSegmentOrder(matched: string[]): string[] {
31
- return matched.filter((id) => {
32
- if (id.includes(".@")) return false;
33
- if (/D\d+\./.test(id)) return false;
34
- return true;
35
- });
36
- }
37
-
38
- /**
39
- * Initialize segments data synchronously for SSR.
40
- * Called before rendering to populate state for useState initializer.
41
- *
42
- * @param matched - Segment order from RSC metadata
43
- * @param pathname - Current pathname
44
- */
45
- export function initSegmentsSync(matched?: string[], pathname?: string): void {
46
- ssrSegmentOrder = filterSegmentOrder(matched ?? []);
47
- ssrPathname = pathname ?? "/";
48
- }
49
-
50
- /**
51
- * Shallow equality check for selector results
52
- */
53
- function shallowEqual<T>(a: T, b: T): boolean {
54
- if (Object.is(a, b)) return true;
55
- if (
56
- typeof a !== "object" ||
57
- a === null ||
58
- typeof b !== "object" ||
59
- b === null
60
- ) {
61
- return false;
62
- }
63
- const keysA = Object.keys(a);
64
- const keysB = Object.keys(b);
65
- if (keysA.length !== keysB.length) return false;
66
- for (const key of keysA) {
67
- if (
68
- !Object.hasOwn(b, key) ||
69
- !Object.is((a as any)[key], (b as any)[key])
70
- ) {
71
- return false;
72
- }
73
- }
74
- return true;
75
- }
76
-
77
19
  /**
78
20
  * Parse pathname into path segments
79
21
  * /shop/products/123 → ["shop", "products", "123"]
@@ -87,7 +29,7 @@ function parsePathname(pathname: string): string[] {
87
29
  */
88
30
  function buildSegmentsState(
89
31
  location: URL,
90
- segmentOrder: string[]
32
+ segmentOrder: string[],
91
33
  ): SegmentsState {
92
34
  return {
93
35
  path: parsePathname(location.pathname),
@@ -96,18 +38,6 @@ function buildSegmentsState(
96
38
  };
97
39
  }
98
40
 
99
- /**
100
- * Build SSR state from module-level variables
101
- */
102
- function buildSsrState(): SegmentsState {
103
- const location = new URL(ssrPathname, "http://localhost");
104
- return {
105
- path: parsePathname(ssrPathname),
106
- segmentIds: ssrSegmentOrder,
107
- location,
108
- };
109
- }
110
-
111
41
  /**
112
42
  * Hook to access current route segments with optional selector for performance
113
43
  *
@@ -127,62 +57,115 @@ function buildSsrState(): SegmentsState {
127
57
  export function useSegments(): SegmentsState;
128
58
  export function useSegments<T>(selector: (state: SegmentsState) => T): T;
129
59
  export function useSegments<T>(
130
- selector?: (state: SegmentsState) => T
60
+ selector?: (state: SegmentsState) => T,
131
61
  ): T | SegmentsState {
132
62
  const ctx = useContext(NavigationStoreContext);
133
63
 
134
- // Build initial state from SSR module state or event controller
64
+ // Build initial state from event controller when context exists.
65
+ // Inlined rather than calling recompute() because the segmentsCache ref
66
+ // is not yet initialized during the useState initializer.
135
67
  const [state, setState] = useState<T | SegmentsState>(() => {
136
- // During SSR or when no context, use module-level SSR state
137
- if (typeof document === "undefined" || !ctx) {
138
- const ssrState = buildSsrState();
139
- return selector ? selector(ssrState) : ssrState;
68
+ if (!ctx) {
69
+ const fallbackLocation = new URL("/", "http://localhost");
70
+ const fallbackState = buildSegmentsState(fallbackLocation, []);
71
+ return selector ? selector(fallbackState) : fallbackState;
140
72
  }
141
- // On client with context, use event controller state
142
- const navState = ctx.eventController.getState();
73
+ const location = ctx.eventController.getLocation();
143
74
  const handleState = ctx.eventController.getHandleState();
144
75
  const segmentsState = buildSegmentsState(
145
- navState.location as URL,
146
- handleState.segmentOrder
76
+ location as URL,
77
+ handleState.segmentOrder,
147
78
  );
148
79
  return selector ? selector(segmentsState) : segmentsState;
149
80
  });
150
81
 
151
82
  const prevState = useRef(state);
83
+ const selectorRef = useRef(selector);
84
+ selectorRef.current = selector;
85
+
86
+ // Track selector identity to detect when the selector function changes.
87
+ // Only then do we eagerly recompute during render to avoid staleness.
88
+ // Without this guard, no-selector mode causes infinite re-renders because
89
+ // buildSegmentsState creates fresh arrays that fail Object.is checks.
90
+ const prevSelectorIdentity = useRef(selector);
91
+
92
+ // Cache SegmentsState to stabilize nested references (path, segmentIds
93
+ // arrays) so selectors returning composite values don't cause spurious
94
+ // render-time setState calls.
95
+ const segmentsCache = useRef<{
96
+ location: URL;
97
+ segmentOrder: string[];
98
+ state: SegmentsState;
99
+ } | null>(null);
100
+
101
+ // Recompute selected value from current store state and apply selector.
102
+ // Shared by the render-time eager check and the subscription callback.
103
+ function recompute(
104
+ sel: ((state: SegmentsState) => T) | undefined,
105
+ ): T | SegmentsState {
106
+ const location = ctx!.eventController.getLocation();
107
+ const handleState = ctx!.eventController.getHandleState();
108
+
109
+ // Reuse cached state when inputs haven't changed by reference,
110
+ // keeping array/object references stable for composite selectors.
111
+ const cache = segmentsCache.current;
112
+ let segmentsState: SegmentsState;
113
+ if (
114
+ cache &&
115
+ cache.location === location &&
116
+ cache.segmentOrder === handleState.segmentOrder
117
+ ) {
118
+ segmentsState = cache.state;
119
+ } else {
120
+ segmentsState = buildSegmentsState(
121
+ location as URL,
122
+ handleState.segmentOrder,
123
+ );
124
+ segmentsCache.current = {
125
+ location: location as URL,
126
+ segmentOrder: handleState.segmentOrder,
127
+ state: segmentsState,
128
+ };
129
+ }
130
+ return sel ? sel(segmentsState) : segmentsState;
131
+ }
132
+
133
+ if (ctx && selector !== prevSelectorIdentity.current) {
134
+ prevSelectorIdentity.current = selector;
135
+ const nextSelected = recompute(selector);
136
+ if (!shallowEqual(nextSelected, prevState.current)) {
137
+ prevState.current = nextSelected;
138
+ setState(nextSelected);
139
+ }
140
+ }
152
141
 
153
- // Subscribe to both navigation state and handle state changes
142
+ // Subscribe to store changes. The eager block above handles selector
143
+ // changes and SSR drift, so no initial updateState() call is needed.
154
144
  useEffect(() => {
155
145
  if (!ctx) {
156
146
  return;
157
147
  }
158
148
 
159
149
  const updateState = () => {
160
- const navState = ctx.eventController.getState();
161
- const handleState = ctx.eventController.getHandleState();
162
- const segmentsState = buildSegmentsState(
163
- navState.location as URL,
164
- handleState.segmentOrder
165
- );
166
- const nextSelected = selector ? selector(segmentsState) : segmentsState;
167
-
150
+ const nextSelected = recompute(selectorRef.current);
168
151
  if (!shallowEqual(nextSelected, prevState.current)) {
169
152
  prevState.current = nextSelected;
170
153
  setState(nextSelected);
171
154
  }
172
155
  };
173
156
 
174
- // Initial update in case SSR state differs from client state
175
- updateState();
176
-
177
- // Subscribe to both state sources
178
157
  const unsubscribeNav = ctx.eventController.subscribe(updateState);
179
- const unsubscribeHandles = ctx.eventController.subscribeToHandles(updateState);
158
+ const unsubscribeHandles =
159
+ ctx.eventController.subscribeToHandles(updateState);
180
160
 
181
161
  return () => {
182
162
  unsubscribeNav();
183
163
  unsubscribeHandles();
184
164
  };
185
- }, [selector]);
165
+ // Stable subscription: selector changes are handled via selectorRef,
166
+ // state comparison uses prevState ref. No re-subscribe needed.
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, []);
186
169
 
187
170
  return state as T | SegmentsState;
188
171
  }
@@ -0,0 +1,73 @@
1
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
2
+
3
+ type HeaderResult = { url: string } | "blocked" | null;
4
+
5
+ /**
6
+ * Extract and validate an RSC response header URL (X-RSC-Reload, X-RSC-Redirect).
7
+ * Returns { url } if valid, "blocked" if present but invalid origin, null if absent.
8
+ */
9
+ export function extractRscHeaderUrl(
10
+ response: Response,
11
+ header: string,
12
+ ): HeaderResult {
13
+ const raw = response.headers.get(header);
14
+ if (!raw) return null;
15
+ const url = validateRedirectOrigin(raw, window.location.origin);
16
+ return url ? { url } : "blocked";
17
+ }
18
+
19
+ /**
20
+ * Empty 200 response that won't choke Flight parsing.
21
+ * Used when a header URL is blocked by origin validation.
22
+ */
23
+ export function emptyResponse(): Response {
24
+ return new Response(null, { status: 200 });
25
+ }
26
+
27
+ /**
28
+ * Tee a response body for RSC parsing and stream completion tracking.
29
+ * Returns a new Response with one branch; the other is consumed to detect
30
+ * end-of-stream, calling onComplete when done.
31
+ *
32
+ * If the response has no body, onComplete fires synchronously.
33
+ * If signal is provided, an abort cancels the tracking reader.
34
+ */
35
+ export function teeWithCompletion(
36
+ response: Response,
37
+ onComplete: () => void,
38
+ signal?: AbortSignal,
39
+ ): Response {
40
+ if (!response.body) {
41
+ onComplete();
42
+ return response;
43
+ }
44
+
45
+ const [rscStream, trackingStream] = response.body.tee();
46
+
47
+ (async () => {
48
+ const reader = trackingStream.getReader();
49
+ const onAbort = signal ? reader.cancel.bind(reader) : undefined;
50
+ if (onAbort) signal!.addEventListener("abort", onAbort, { once: true });
51
+ try {
52
+ while (true) {
53
+ const { done } = await reader.read();
54
+ if (done) break;
55
+ }
56
+ } finally {
57
+ if (onAbort) signal!.removeEventListener("abort", onAbort);
58
+ reader.releaseLock();
59
+ onComplete();
60
+ }
61
+ })().catch((error) => {
62
+ if (!signal?.aborted) {
63
+ console.error("[Browser] Error reading tracking stream:", error);
64
+ }
65
+ onComplete();
66
+ });
67
+
68
+ return new Response(rscStream, {
69
+ headers: response.headers,
70
+ status: response.status,
71
+ statusText: response.statusText,
72
+ });
73
+ }
@@ -11,8 +11,7 @@ import { createEventController } from "./event-controller.js";
11
11
  import { createNavigationClient } from "./navigation-client.js";
12
12
  import { createServerActionBridge } from "./server-action-bridge.js";
13
13
  import { createNavigationBridge } from "./navigation-bridge.js";
14
- import { NavigationProvider, initHandleDataSync, initSegmentsSync } from "./react/index.js";
15
- import { initThemeConfigSync } from "../theme/theme-context.js";
14
+ import { NavigationProvider } from "./react/index.js";
16
15
  import type {
17
16
  RscPayload,
18
17
  RscBrowserDependencies,
@@ -22,6 +21,12 @@ import type {
22
21
  } from "./types.js";
23
22
  import type { EventController } from "./event-controller.js";
24
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
+ import { initRangoState } from "./rango-state.js";
25
+ import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import {
27
+ isInterceptSegment,
28
+ splitInterceptSegments,
29
+ } from "./intercept-utils.js";
25
30
 
26
31
  // Vite HMR types are provided by vite/client
27
32
 
@@ -106,6 +111,8 @@ export interface BrowserAppContext {
106
111
  initialTheme?: Theme;
107
112
  /** Whether connection warmup is enabled */
108
113
  warmupEnabled?: boolean;
114
+ /** App version for prefetch version mismatch detection */
115
+ version?: string;
109
116
  }
110
117
 
111
118
  // Module-level state for the initialized app
@@ -121,9 +128,16 @@ let browserAppContext: BrowserAppContext | null = null;
121
128
  * - Configures HMR support
122
129
  */
123
130
  export async function initBrowserApp(
124
- options: InitBrowserAppOptions
131
+ options: InitBrowserAppOptions,
125
132
  ): Promise<BrowserAppContext> {
126
- const { rscStream, deps, storeOptions, linkInterception = true, themeConfig, initialTheme } = options;
133
+ const {
134
+ rscStream,
135
+ deps,
136
+ storeOptions,
137
+ linkInterception = true,
138
+ themeConfig,
139
+ initialTheme,
140
+ } = options;
127
141
 
128
142
  // Load initial payload from SSR-injected __FLIGHT_DATA__
129
143
  const initialPayload =
@@ -131,8 +145,10 @@ export async function initBrowserApp(
131
145
 
132
146
  // Extract themeConfig and initialTheme from payload if not explicitly provided
133
147
  // This allows virtual entries to work without importing the router
134
- const effectiveThemeConfig = themeConfig ?? initialPayload.metadata?.themeConfig ?? null;
135
- const effectiveInitialTheme = initialTheme ?? initialPayload.metadata?.initialTheme;
148
+ const effectiveThemeConfig =
149
+ themeConfig ?? initialPayload.metadata?.themeConfig ?? null;
150
+ const effectiveInitialTheme =
151
+ initialTheme ?? initialPayload.metadata?.initialTheme;
136
152
 
137
153
  // Get initial segments and compute history key from current URL
138
154
  const initialSegments = (initialPayload.metadata?.segments ??
@@ -153,15 +169,12 @@ export async function initBrowserApp(
153
169
  initialLocation: new URL(window.location.href),
154
170
  });
155
171
 
156
- // Initialize segments state BEFORE hydration to avoid mismatch
157
- initSegmentsSync(initialPayload.metadata?.matched, initialPayload.metadata?.pathname);
158
-
159
- // Initialize theme config for MetaTags (must match SSR state)
160
- initThemeConfigSync(effectiveThemeConfig);
161
-
162
172
  // Initialize event controller with segment order (even without handles)
163
173
  eventController.setHandleData({}, initialPayload.metadata?.matched);
164
174
 
175
+ // Initialize route params
176
+ eventController.setParams(initialPayload.metadata?.params ?? {});
177
+
165
178
  // Initialize handle data from initial payload BEFORE hydration
166
179
  // This ensures useHandle returns correct data during hydration to avoid mismatch
167
180
  // The handles property is an async generator that yields on each push
@@ -171,16 +184,17 @@ export async function initBrowserApp(
171
184
  for await (const handleData of handlesGenerator) {
172
185
  lastHandleData = handleData;
173
186
  }
174
- // Initialize both event controller AND module-level SSR state for hydration compatibility
175
- eventController.setHandleData(lastHandleData, initialPayload.metadata?.matched);
176
- initHandleDataSync(lastHandleData, initialPayload.metadata?.matched);
187
+ // Initialize event controller with initial handle state before hydration.
188
+ eventController.setHandleData(
189
+ lastHandleData,
190
+ initialPayload.metadata?.matched,
191
+ );
177
192
 
178
193
  // Update the initial cache entry with the processed handleData
179
194
  // The cache entry was created by createNavigationStore but without handleData
180
195
  store.updateCacheHandleData(initialHistoryKey, lastHandleData);
181
196
  }
182
197
 
183
-
184
198
  // Create composable utilities
185
199
  const client = createNavigationClient(deps);
186
200
 
@@ -188,12 +202,27 @@ export async function initBrowserApp(
188
202
  const rootLayout = initialPayload.metadata?.rootLayout;
189
203
  const version = initialPayload.metadata?.version;
190
204
 
205
+ // Initialize the localStorage state key for cache invalidation.
206
+ // Uses the build version so a new deploy automatically busts all cached prefetches.
207
+ initRangoState(version ?? "0");
208
+
209
+ // Initialize the in-memory prefetch cache TTL from server config.
210
+ // A value of 0 disables the cache; undefined falls back to the module default.
211
+ const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
212
+ if (prefetchCacheTTL !== undefined) {
213
+ initPrefetchCache(prefetchCacheTTL);
214
+ }
215
+
191
216
  // Create a bound renderSegments that includes rootLayout
192
217
  const renderSegments = (
193
218
  segments: ResolvedSegment[],
194
- options?: RenderSegmentsOptions
219
+ options?: RenderSegmentsOptions,
195
220
  ) => baseRenderSegments(segments, { ...options, rootLayout });
196
221
 
222
+ // Lazy reference for navigation bridge — the action bridge is created first
223
+ // but may need to trigger SPA navigation for action redirects.
224
+ let navigateFn: ((url: string, options?: any) => Promise<void>) | null = null;
225
+
197
226
  // Setup server action bridge
198
227
  const actionBridge = createServerActionBridge({
199
228
  store,
@@ -203,6 +232,13 @@ export async function initBrowserApp(
203
232
  onUpdate: (update) => store.emitUpdate(update),
204
233
  renderSegments,
205
234
  version,
235
+ onNavigate: (url, options) => {
236
+ if (!navigateFn) {
237
+ window.location.href = url;
238
+ return Promise.resolve();
239
+ }
240
+ return navigateFn(url, options);
241
+ },
206
242
  });
207
243
  actionBridge.register();
208
244
 
@@ -216,6 +252,9 @@ export async function initBrowserApp(
216
252
  version,
217
253
  });
218
254
 
255
+ // Connect action redirect → navigation bridge (now that both are initialized)
256
+ navigateFn = (url, options) => navigationBridge.navigate(url, options);
257
+
219
258
  // Optionally enable global link interception
220
259
  if (linkInterception) {
221
260
  navigationBridge.registerLinkInterception();
@@ -224,47 +263,123 @@ export async function initBrowserApp(
224
263
  // Build initial tree with rootLayout
225
264
  const initialTree = renderSegments(initialPayload.metadata!.segments);
226
265
 
227
- // Setup HMR
266
+ // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
267
+ // fire many rsc:update events in quick succession. Without debouncing,
268
+ // each event triggers a fetchPartial() which on slow routes can pile up
269
+ // and overwhelm the worker (cross-request promise issues, 500s).
228
270
  if (import.meta.hot) {
229
- import.meta.hot.on("rsc:update", async () => {
230
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
231
-
232
- const handle = eventController.startNavigation(window.location.href, {
233
- replace: true,
234
- });
235
- const streamingToken = handle.startStreaming();
236
-
237
- try {
238
- const { payload, streamComplete } = await client.fetchPartial({
239
- targetUrl: window.location.href,
240
- segmentIds: [],
241
- previousUrl: store.getSegmentState().currentUrl,
242
- });
271
+ let hmrTimer: ReturnType<typeof setTimeout> | null = null;
272
+ let hmrAbort: AbortController | null = null;
273
+
274
+ import.meta.hot.on("rsc:update", () => {
275
+ // Cancel any pending debounce timer
276
+ if (hmrTimer !== null) {
277
+ clearTimeout(hmrTimer);
278
+ }
279
+
280
+ // Abort any in-flight HMR fetch so it doesn't race with the next one
281
+ if (hmrAbort) {
282
+ hmrAbort.abort();
283
+ hmrAbort = null;
284
+ }
243
285
 
244
- if (payload.metadata?.isPartial) {
245
- const segments = payload.metadata.segments || [];
246
- const matched = payload.metadata.matched || [];
286
+ // Debounce: wait 200ms of quiet before fetching
287
+ hmrTimer = setTimeout(async () => {
288
+ hmrTimer = null;
289
+
290
+ // Don't interrupt an active user navigation — startNavigation()
291
+ // would abort it and refetch the old URL (window.location.href
292
+ // hasn't updated yet). The user's navigation will pick up the
293
+ // new server code when it completes. isNavigating covers the
294
+ // full lifecycle (fetching + streaming, before commit) without
295
+ // blocking on server actions.
296
+ if (eventController.getState().isNavigating) {
297
+ console.log("[RSCRouter] HMR: Skipping — navigation in progress");
298
+ return;
299
+ }
247
300
 
248
- store.setSegmentIds(matched);
249
- store.setCurrentUrl(window.location.href);
301
+ console.log("[RSCRouter] HMR: Server update, refetching RSC");
250
302
 
251
- const historyKey = generateHistoryKey(window.location.href);
252
- store.setHistoryKey(historyKey);
253
- const currentHandleData = eventController.getHandleState().data;
254
- store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
303
+ const abort = new AbortController();
304
+ hmrAbort = abort;
255
305
 
256
- store.emitUpdate({
257
- root: renderSegments(segments),
258
- metadata: payload.metadata,
306
+ const handle = eventController.startNavigation(window.location.href, {
307
+ replace: true,
308
+ });
309
+ const streamingToken = handle.startStreaming();
310
+
311
+ const interceptSourceUrl = store.getInterceptSourceUrl();
312
+
313
+ try {
314
+ const { payload, streamComplete } = await client.fetchPartial({
315
+ targetUrl: window.location.href,
316
+ segmentIds: [],
317
+ previousUrl: store.getSegmentState().currentUrl,
318
+ interceptSourceUrl: interceptSourceUrl || undefined,
319
+ hmr: true,
320
+ signal: abort.signal,
259
321
  });
260
- }
261
322
 
262
- await streamComplete;
263
- } finally {
264
- streamingToken.end();
265
- }
266
- handle.complete(new URL(window.location.href));
267
- console.log("[RSCRouter] HMR: RSC stream complete");
323
+ if (abort.signal.aborted) return;
324
+
325
+ // If the server returned a non-RSC response (404, 500 without
326
+ // error boundary), the payload won't have valid metadata.
327
+ // Reload to recover rather than leaving the page stale.
328
+ if (!payload.metadata) {
329
+ throw new Error("HMR refetch returned invalid payload");
330
+ }
331
+
332
+ if (payload.metadata?.isPartial) {
333
+ const segments = payload.metadata.segments || [];
334
+ const matched = payload.metadata.matched || [];
335
+
336
+ // Derive intercept state from the returned payload, not the
337
+ // pre-fetch store snapshot. If the HMR edit removed intercept
338
+ // behavior, the response won't contain intercept segments.
339
+ const responseIsIntercept = segments.some(isInterceptSegment);
340
+
341
+ // Sync store intercept state with what the server returned
342
+ if (!responseIsIntercept && interceptSourceUrl) {
343
+ store.setInterceptSourceUrl(null);
344
+ }
345
+
346
+ store.setSegmentIds(matched);
347
+ store.setCurrentUrl(window.location.href);
348
+
349
+ const historyKey = generateHistoryKey(window.location.href, {
350
+ intercept: responseIsIntercept,
351
+ });
352
+ store.setHistoryKey(historyKey);
353
+ const currentHandleData = eventController.getHandleState().data;
354
+ store.cacheSegmentsForHistory(
355
+ historyKey,
356
+ segments,
357
+ currentHandleData,
358
+ );
359
+
360
+ const { main, intercept } = splitInterceptSegments(segments);
361
+ store.emitUpdate({
362
+ root: renderSegments(main, {
363
+ interceptSegments: intercept.length > 0 ? intercept : undefined,
364
+ }),
365
+ metadata: payload.metadata,
366
+ });
367
+ }
368
+
369
+ await streamComplete;
370
+ handle.complete(new URL(window.location.href));
371
+ console.log("[RSCRouter] HMR: RSC stream complete");
372
+ } catch (err) {
373
+ if (abort.signal.aborted) return;
374
+ console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
375
+ window.location.reload();
376
+ return;
377
+ } finally {
378
+ if (hmrAbort === abort) hmrAbort = null;
379
+ streamingToken.end();
380
+ handle[Symbol.dispose]();
381
+ }
382
+ }, 200);
268
383
  });
269
384
  }
270
385
 
@@ -278,6 +393,7 @@ export async function initBrowserApp(
278
393
  themeConfig: effectiveThemeConfig,
279
394
  initialTheme: effectiveInitialTheme,
280
395
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
396
+ version,
281
397
  };
282
398
  browserAppContext = context;
283
399
 
@@ -290,7 +406,7 @@ export async function initBrowserApp(
290
406
  export function getBrowserAppContext(): BrowserAppContext {
291
407
  if (!browserAppContext) {
292
408
  throw new Error(
293
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter"
409
+ "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
294
410
  );
295
411
  }
296
412
  return browserAppContext;
@@ -333,18 +449,35 @@ export interface RSCRouterProps {}
333
449
  * ```
334
450
  */
335
451
  export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
336
- const { store, eventController, bridge, initialPayload, initialTree, themeConfig, initialTheme, warmupEnabled } =
337
- getBrowserAppContext();
452
+ const {
453
+ store,
454
+ eventController,
455
+ bridge,
456
+ initialPayload,
457
+ initialTree,
458
+ themeConfig,
459
+ initialTheme,
460
+ warmupEnabled,
461
+ version,
462
+ } = getBrowserAppContext();
463
+
464
+ // Signal that the React tree has hydrated. useEffect only fires after
465
+ // hydration completes, so this attribute is a stable readiness marker
466
+ // that does not depend on React internals like __reactFiber.
467
+ React.useEffect(() => {
468
+ document.documentElement.dataset.hydrated = "";
469
+ }, []);
338
470
 
339
471
  return (
340
472
  <NavigationProvider
341
473
  store={store}
342
474
  eventController={eventController}
343
- initialPayload={{ ...initialPayload, root: initialTree }}
475
+ initialPayload={{ root: initialTree, metadata: initialPayload.metadata! }}
344
476
  bridge={bridge}
345
477
  themeConfig={themeConfig}
346
478
  initialTheme={initialTheme}
347
479
  warmupEnabled={warmupEnabled}
480
+ version={version}
348
481
  />
349
482
  );
350
483
  }