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

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 (285) hide show
  1. package/README.md +196 -43
  2. package/dist/bin/rango.js +277 -99
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2779 -1064
  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 +57 -11
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +155 -6
  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 +249 -17
  21. package/skills/loader/SKILL.md +273 -53
  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 +123 -100
  29. package/skills/rango/SKILL.md +242 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +88 -4
  33. package/skills/router-setup/SKILL.md +90 -5
  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 +716 -0
  37. package/skills/typesafety/SKILL.md +329 -27
  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 +1 -1
  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/navigation-bridge.ts +102 -16
  50. package/src/browser/navigation-client.ts +164 -59
  51. package/src/browser/navigation-store.ts +75 -17
  52. package/src/browser/navigation-transaction.ts +21 -37
  53. package/src/browser/partial-update.ts +139 -38
  54. package/src/browser/prefetch/cache.ts +175 -15
  55. package/src/browser/prefetch/fetch.ts +180 -33
  56. package/src/browser/prefetch/queue.ts +123 -20
  57. package/src/browser/prefetch/resource-ready.ts +77 -0
  58. package/src/browser/rango-state.ts +53 -13
  59. package/src/browser/react/Link.tsx +81 -9
  60. package/src/browser/react/NavigationProvider.tsx +110 -33
  61. package/src/browser/react/context.ts +7 -2
  62. package/src/browser/react/filter-segment-order.ts +51 -7
  63. package/src/browser/react/index.ts +3 -0
  64. package/src/browser/react/location-state-shared.ts +175 -4
  65. package/src/browser/react/location-state.ts +39 -13
  66. package/src/browser/react/use-handle.ts +23 -64
  67. package/src/browser/react/use-navigation.ts +22 -2
  68. package/src/browser/react/use-params.ts +20 -8
  69. package/src/browser/react/use-reverse.ts +106 -0
  70. package/src/browser/react/use-router.ts +43 -10
  71. package/src/browser/react/use-segments.ts +11 -8
  72. package/src/browser/response-adapter.ts +25 -0
  73. package/src/browser/rsc-router.tsx +191 -74
  74. package/src/browser/scroll-restoration.ts +41 -14
  75. package/src/browser/segment-reconciler.ts +36 -9
  76. package/src/browser/segment-structure-assert.ts +2 -2
  77. package/src/browser/server-action-bridge.ts +31 -36
  78. package/src/browser/types.ts +57 -5
  79. package/src/build/collect-fallback-refs.ts +107 -0
  80. package/src/build/generate-manifest.ts +65 -40
  81. package/src/build/generate-route-types.ts +5 -0
  82. package/src/build/index.ts +2 -0
  83. package/src/build/route-trie.ts +52 -25
  84. package/src/build/route-types/codegen.ts +4 -4
  85. package/src/build/route-types/include-resolution.ts +9 -2
  86. package/src/build/route-types/per-module-writer.ts +7 -4
  87. package/src/build/route-types/router-processing.ts +278 -88
  88. package/src/build/route-types/scan-filter.ts +9 -2
  89. package/src/build/route-types/source-scan.ts +118 -0
  90. package/src/build/runtime-discovery.ts +9 -20
  91. package/src/cache/cache-runtime.ts +15 -11
  92. package/src/cache/cache-scope.ts +76 -49
  93. package/src/cache/cf/cf-cache-store.ts +501 -18
  94. package/src/cache/cf/index.ts +5 -1
  95. package/src/cache/document-cache.ts +17 -7
  96. package/src/cache/index.ts +1 -0
  97. package/src/cache/taint.ts +55 -0
  98. package/src/client.rsc.tsx +3 -0
  99. package/src/client.tsx +94 -238
  100. package/src/context-var.ts +72 -2
  101. package/src/debug.ts +2 -2
  102. package/src/decode-loader-results.ts +36 -0
  103. package/src/errors.ts +30 -1
  104. package/src/handle.ts +65 -12
  105. package/src/host/index.ts +2 -2
  106. package/src/host/router.ts +129 -57
  107. package/src/host/types.ts +31 -2
  108. package/src/host/utils.ts +1 -1
  109. package/src/href-client.ts +140 -20
  110. package/src/index.rsc.ts +12 -5
  111. package/src/index.ts +61 -11
  112. package/src/loader-store.ts +500 -0
  113. package/src/loader.rsc.ts +2 -5
  114. package/src/loader.ts +3 -10
  115. package/src/missing-id-error.ts +68 -0
  116. package/src/outlet-context.ts +1 -1
  117. package/src/prerender/store.ts +5 -4
  118. package/src/prerender.ts +141 -80
  119. package/src/response-utils.ts +37 -0
  120. package/src/reverse.ts +65 -15
  121. package/src/route-content-wrapper.tsx +6 -28
  122. package/src/route-definition/dsl-helpers.ts +435 -260
  123. package/src/route-definition/helper-factories.ts +29 -139
  124. package/src/route-definition/helpers-types.ts +110 -34
  125. package/src/route-definition/index.ts +3 -0
  126. package/src/route-definition/redirect.ts +11 -3
  127. package/src/route-definition/resolve-handler-use.ts +155 -0
  128. package/src/route-definition/use-item-types.ts +32 -0
  129. package/src/route-map-builder.ts +7 -1
  130. package/src/route-types.ts +37 -41
  131. package/src/router/basename.ts +14 -0
  132. package/src/router/content-negotiation.ts +113 -1
  133. package/src/router/error-handling.ts +1 -1
  134. package/src/router/find-match.ts +4 -2
  135. package/src/router/handler-context.ts +77 -38
  136. package/src/router/intercept-resolution.ts +15 -22
  137. package/src/router/lazy-includes.ts +12 -9
  138. package/src/router/loader-resolution.ts +174 -22
  139. package/src/router/logging.ts +5 -2
  140. package/src/router/manifest.ts +31 -16
  141. package/src/router/match-api.ts +128 -192
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/background-revalidation.ts +30 -2
  144. package/src/router/match-middleware/cache-lookup.ts +136 -106
  145. package/src/router/match-middleware/cache-store.ts +54 -10
  146. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  147. package/src/router/match-middleware/segment-resolution.ts +61 -5
  148. package/src/router/match-result.ts +125 -10
  149. package/src/router/metrics.ts +7 -2
  150. package/src/router/middleware-types.ts +21 -34
  151. package/src/router/middleware.ts +103 -90
  152. package/src/router/navigation-snapshot.ts +182 -0
  153. package/src/router/pattern-matching.ts +101 -17
  154. package/src/router/prerender-match.ts +110 -10
  155. package/src/router/preview-match.ts +32 -102
  156. package/src/router/request-classification.ts +286 -0
  157. package/src/router/revalidation.ts +58 -2
  158. package/src/router/route-snapshot.ts +245 -0
  159. package/src/router/router-context.ts +6 -1
  160. package/src/router/router-interfaces.ts +77 -28
  161. package/src/router/router-options.ts +76 -11
  162. package/src/router/router-registry.ts +2 -5
  163. package/src/router/segment-resolution/fresh.ts +223 -24
  164. package/src/router/segment-resolution/helpers.ts +29 -24
  165. package/src/router/segment-resolution/loader-cache.ts +1 -0
  166. package/src/router/segment-resolution/revalidation.ts +466 -285
  167. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  168. package/src/router/segment-wrappers.ts +2 -0
  169. package/src/router/substitute-pattern-params.ts +56 -0
  170. package/src/router/telemetry.ts +99 -0
  171. package/src/router/trie-matching.ts +18 -13
  172. package/src/router/types.ts +9 -0
  173. package/src/router/url-params.ts +49 -0
  174. package/src/router.ts +91 -23
  175. package/src/rsc/handler-context.ts +2 -2
  176. package/src/rsc/handler.ts +440 -381
  177. package/src/rsc/helpers.ts +91 -43
  178. package/src/rsc/index.ts +1 -1
  179. package/src/rsc/loader-fetch.ts +23 -3
  180. package/src/rsc/manifest-init.ts +5 -1
  181. package/src/rsc/origin-guard.ts +28 -10
  182. package/src/rsc/progressive-enhancement.ts +18 -2
  183. package/src/rsc/response-route-handler.ts +46 -53
  184. package/src/rsc/rsc-rendering.ts +41 -48
  185. package/src/rsc/runtime-warnings.ts +9 -10
  186. package/src/rsc/server-action.ts +25 -37
  187. package/src/rsc/ssr-setup.ts +18 -2
  188. package/src/rsc/types.ts +17 -3
  189. package/src/search-params.ts +4 -4
  190. package/src/segment-content-promise.ts +67 -0
  191. package/src/segment-loader-promise.ts +122 -0
  192. package/src/segment-system.tsx +219 -67
  193. package/src/serialize.ts +243 -0
  194. package/src/server/context.ts +277 -61
  195. package/src/server/cookie-store.ts +28 -4
  196. package/src/server/handle-store.ts +19 -0
  197. package/src/server/loader-registry.ts +9 -8
  198. package/src/server/request-context.ts +204 -60
  199. package/src/ssr/index.tsx +9 -1
  200. package/src/static-handler.ts +19 -7
  201. package/src/testing/cache-status.ts +166 -0
  202. package/src/testing/collect-handle.ts +63 -0
  203. package/src/testing/dispatch.ts +440 -0
  204. package/src/testing/dom.entry.ts +22 -0
  205. package/src/testing/e2e/fixture.ts +154 -0
  206. package/src/testing/e2e/index.ts +149 -0
  207. package/src/testing/e2e/matchers.ts +51 -0
  208. package/src/testing/e2e/page-helpers.ts +272 -0
  209. package/src/testing/e2e/parity.ts +306 -0
  210. package/src/testing/e2e/server.ts +183 -0
  211. package/src/testing/flight-matchers.ts +104 -0
  212. package/src/testing/flight-runtime.d.ts +21 -0
  213. package/src/testing/flight.entry.ts +22 -0
  214. package/src/testing/flight.ts +182 -0
  215. package/src/testing/generated-routes.ts +223 -0
  216. package/src/testing/index.ts +106 -0
  217. package/src/testing/internal/context.ts +255 -0
  218. package/src/testing/render-route.tsx +565 -0
  219. package/src/testing/run-loader.ts +296 -0
  220. package/src/testing/run-middleware.ts +179 -0
  221. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  222. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  223. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  224. package/src/testing/vitest-stubs/version.ts +5 -0
  225. package/src/testing/vitest.ts +183 -0
  226. package/src/types/cache-types.ts +4 -4
  227. package/src/types/global-namespace.ts +39 -26
  228. package/src/types/handler-context.ts +194 -72
  229. package/src/types/index.ts +1 -0
  230. package/src/types/loader-types.ts +41 -15
  231. package/src/types/request-scope.ts +126 -0
  232. package/src/types/route-entry.ts +19 -1
  233. package/src/types/segments.ts +37 -1
  234. package/src/urls/include-helper.ts +34 -67
  235. package/src/urls/index.ts +0 -3
  236. package/src/urls/path-helper-types.ts +50 -9
  237. package/src/urls/path-helper.ts +63 -63
  238. package/src/urls/pattern-types.ts +48 -19
  239. package/src/urls/response-types.ts +25 -22
  240. package/src/urls/type-extraction.ts +26 -116
  241. package/src/urls/urls-function.ts +1 -5
  242. package/src/use-loader.tsx +487 -44
  243. package/src/vite/debug.ts +185 -0
  244. package/src/vite/discovery/bundle-postprocess.ts +34 -37
  245. package/src/vite/discovery/discover-routers.ts +105 -51
  246. package/src/vite/discovery/discovery-errors.ts +194 -0
  247. package/src/vite/discovery/gate-state.ts +171 -0
  248. package/src/vite/discovery/prerender-collection.ts +188 -93
  249. package/src/vite/discovery/route-types-writer.ts +40 -84
  250. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  251. package/src/vite/discovery/state.ts +46 -6
  252. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  253. package/src/vite/index.ts +6 -0
  254. package/src/vite/plugin-types.ts +111 -72
  255. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  256. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  257. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  258. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  259. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  260. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  261. package/src/vite/plugins/expose-action-id.ts +55 -33
  262. package/src/vite/plugins/expose-id-utils.ts +24 -8
  263. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  264. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  265. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  266. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  267. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  268. package/src/vite/plugins/performance-tracks.ts +92 -0
  269. package/src/vite/plugins/refresh-cmd.ts +88 -26
  270. package/src/vite/plugins/use-cache-transform.ts +65 -50
  271. package/src/vite/plugins/version-injector.ts +39 -23
  272. package/src/vite/plugins/version-plugin.ts +72 -3
  273. package/src/vite/plugins/virtual-entries.ts +2 -2
  274. package/src/vite/rango.ts +265 -226
  275. package/src/vite/router-discovery.ts +920 -137
  276. package/src/vite/utils/ast-handler-extract.ts +15 -15
  277. package/src/vite/utils/banner.ts +4 -4
  278. package/src/vite/utils/bundle-analysis.ts +4 -2
  279. package/src/vite/utils/client-chunks.ts +190 -0
  280. package/src/vite/utils/forward-user-plugins.ts +193 -0
  281. package/src/vite/utils/manifest-utils.ts +21 -5
  282. package/src/vite/utils/package-resolution.ts +41 -1
  283. package/src/vite/utils/prerender-utils.ts +38 -5
  284. package/src/vite/utils/shared-utils.ts +109 -27
  285. package/src/browser/action-response-classifier.ts +0 -99
@@ -3,6 +3,7 @@
3
3
  import { useContext, useMemo } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
5
  import { prefetchDirect } from "../prefetch/fetch.js";
6
+ import { getAppVersion } from "../app-version.js";
6
7
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
7
8
 
8
9
  /**
@@ -12,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
12
13
  * useRouter() do not re-render on navigation state changes.
13
14
  * For reactive navigation state, use useNavigation() instead.
14
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
+ *
15
20
  * @example
16
21
  * ```tsx
17
22
  * const router = useRouter();
@@ -28,15 +33,26 @@ export function useRouter(): RouterInstance {
28
33
  throw new Error("useRouter must be used within NavigationProvider");
29
34
  }
30
35
 
31
- // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
32
- return useMemo<RouterInstance>(
33
- () => ({
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.
40
+ return useMemo<RouterInstance>(() => {
41
+ /** Prefix a root-relative path with basename if not already prefixed. */
42
+ function withBasename(url: string): string {
43
+ const bn = ctx!.basename;
44
+ if (!bn || !url.startsWith("/") || url.startsWith(bn + "/") || url === bn)
45
+ return url;
46
+ return url === "/" ? bn : bn + url;
47
+ }
48
+
49
+ return {
34
50
  push(url: string, options?: RouterNavigateOptions): Promise<void> {
35
- return ctx.navigate(url, { ...options, replace: false });
51
+ return ctx.navigate(withBasename(url), { ...options, replace: false });
36
52
  },
37
53
 
38
54
  replace(url: string, options?: RouterNavigateOptions): Promise<void> {
39
- return ctx.navigate(url, { ...options, replace: true });
55
+ return ctx.navigate(withBasename(url), { ...options, replace: true });
40
56
  },
41
57
 
42
58
  refresh(): Promise<void> {
@@ -46,18 +62,35 @@ export function useRouter(): RouterInstance {
46
62
  prefetch(url: string): void {
47
63
  const segmentState = ctx.store?.getSegmentState();
48
64
  if (segmentState) {
49
- prefetchDirect(url, segmentState.currentSegmentIds, ctx.version);
65
+ prefetchDirect(
66
+ withBasename(url),
67
+ segmentState.currentSegmentIds,
68
+ getAppVersion(),
69
+ ctx.store?.getRouterId?.(),
70
+ );
50
71
  }
51
72
  },
52
73
 
53
74
  back(): void {
54
- 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
+ }
55
89
  },
56
90
 
57
91
  forward(): void {
58
92
  window.history.forward();
59
93
  },
60
- }),
61
- [],
62
- );
94
+ };
95
+ }, []);
63
96
  }
@@ -25,15 +25,18 @@ function parsePathname(pathname: string): string[] {
25
25
  }
26
26
 
27
27
  /**
28
- * Build segments state from event controller
28
+ * Build segments state from event controller. `segmentIds` is the
29
+ * route-only list (parallels and loaders stripped) — distinct from the
30
+ * controller's `segmentOrder` which drives handle collection and includes
31
+ * parallel slot ids.
29
32
  */
30
33
  function buildSegmentsState(
31
34
  location: URL,
32
- segmentOrder: string[],
35
+ routeSegmentIds: string[],
33
36
  ): SegmentsState {
34
37
  return {
35
38
  path: parsePathname(location.pathname),
36
- segmentIds: segmentOrder,
39
+ segmentIds: routeSegmentIds,
37
40
  location,
38
41
  };
39
42
  }
@@ -74,7 +77,7 @@ export function useSegments<T>(
74
77
  const handleState = ctx.eventController.getHandleState();
75
78
  const segmentsState = buildSegmentsState(
76
79
  location as URL,
77
- handleState.segmentOrder,
80
+ handleState.routeSegmentIds,
78
81
  );
79
82
  return selector ? selector(segmentsState) : segmentsState;
80
83
  });
@@ -94,7 +97,7 @@ export function useSegments<T>(
94
97
  // render-time setState calls.
95
98
  const segmentsCache = useRef<{
96
99
  location: URL;
97
- segmentOrder: string[];
100
+ routeSegmentIds: string[];
98
101
  state: SegmentsState;
99
102
  } | null>(null);
100
103
 
@@ -113,17 +116,17 @@ export function useSegments<T>(
113
116
  if (
114
117
  cache &&
115
118
  cache.location === location &&
116
- cache.segmentOrder === handleState.segmentOrder
119
+ cache.routeSegmentIds === handleState.routeSegmentIds
117
120
  ) {
118
121
  segmentsState = cache.state;
119
122
  } else {
120
123
  segmentsState = buildSegmentsState(
121
124
  location as URL,
122
- handleState.segmentOrder,
125
+ handleState.routeSegmentIds,
123
126
  );
124
127
  segmentsCache.current = {
125
128
  location: location as URL,
126
- segmentOrder: handleState.segmentOrder,
129
+ routeSegmentIds: handleState.routeSegmentIds,
127
130
  state: segmentsState,
128
131
  };
129
132
  }
@@ -24,6 +24,31 @@ export function emptyResponse(): Response {
24
24
  return new Response(null, { status: 200 });
25
25
  }
26
26
 
27
+ /**
28
+ * Handle the X-RSC-Reload control header (server requests a full page reload on
29
+ * a version mismatch). Returns a short-circuit response when the header is
30
+ * present -- emptyResponse() if the URL was blocked by origin validation, or a
31
+ * never-resolving promise while the page reloads -- and null when absent, so
32
+ * the caller continues processing (e.g. the X-RSC-Redirect check). Scoped to
33
+ * X-RSC-Reload only; redirect handling differs between callers.
34
+ */
35
+ export function handleReloadHeader(
36
+ response: Response,
37
+ opts: { onBlocked: () => void; onReload: (url: string) => void },
38
+ ): Response | Promise<Response> | null {
39
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
40
+ if (reload === "blocked") {
41
+ opts.onBlocked();
42
+ return emptyResponse();
43
+ }
44
+ if (reload) {
45
+ opts.onReload(reload.url);
46
+ window.location.href = reload.url;
47
+ return new Promise<Response>(() => {});
48
+ }
49
+ return null;
50
+ }
51
+
27
52
  /**
28
53
  * Tee a response body for RSC parsing and stream completion tracking.
29
54
  * Returns a new Response with one branch; the other is consumed to detect
@@ -23,10 +23,12 @@ import type { EventController } from "./event-controller.js";
23
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
24
  import { initRangoState } from "./rango-state.js";
25
25
  import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import { setAppVersion } from "./app-version.js";
26
27
  import {
27
28
  isInterceptSegment,
28
29
  splitInterceptSegments,
29
30
  } from "./intercept-utils.js";
31
+ import { createAppShellRef } from "./app-shell.js";
30
32
 
31
33
  // Vite HMR types are provided by vite/client
32
34
 
@@ -113,13 +115,20 @@ export interface BrowserAppContext {
113
115
  warmupEnabled?: boolean;
114
116
  /** App version for prefetch version mismatch detection */
115
117
  version?: string;
118
+ /**
119
+ * Live app-shell ref. Cross-app navigations replace its contents so the
120
+ * NavigationProvider and renderSegments pick up the target app's
121
+ * rootLayout, basename, and version without consumer rerenders. Theme,
122
+ * warmup, and prefetch TTL are document-lifetime (see AppShell).
123
+ */
124
+ appShellRef?: import("./app-shell.js").AppShellRef;
116
125
  }
117
126
 
118
127
  // Module-level state for the initialized app
119
128
  let browserAppContext: BrowserAppContext | null = null;
120
129
 
121
130
  /**
122
- * Initialize the browser app. Must be called before rendering RSCRouter.
131
+ * Initialize the browser app. Must be called before rendering Rango.
123
132
  *
124
133
  * This function:
125
134
  * - Loads the initial RSC payload from the stream
@@ -139,7 +148,6 @@ export async function initBrowserApp(
139
148
  initialTheme,
140
149
  } = options;
141
150
 
142
- // Load initial payload from SSR-injected __FLIGHT_DATA__
143
151
  const initialPayload =
144
152
  await deps.createFromReadableStream<RscPayload>(rscStream);
145
153
 
@@ -164,6 +172,12 @@ export async function initBrowserApp(
164
172
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
165
173
  });
166
174
 
175
+ // Seed router identity from the initial SSR payload so the first
176
+ // cross-app SPA navigation can detect the app switch.
177
+ if (initialPayload.metadata?.routerId) {
178
+ store.setRouterId?.(initialPayload.metadata.routerId);
179
+ }
180
+
167
181
  // Create event controller for reactive state management
168
182
  const eventController = createEventController({
169
183
  initialLocation: new URL(window.location.href),
@@ -198,13 +212,24 @@ export async function initBrowserApp(
198
212
  // Create composable utilities
199
213
  const client = createNavigationClient(deps);
200
214
 
201
- // Extract rootLayout and version from metadata for browser-side re-renders
202
- const rootLayout = initialPayload.metadata?.rootLayout;
215
+ // Capture the per-router app-shell so cross-app navigations can replace
216
+ // it atomically. rootLayout, basename, and version live here and are
217
+ // read through the ref at call time rather than closed over. Theme,
218
+ // warmup, and prefetch TTL are deliberately excluded — they are
219
+ // document-lifetime and stay stable across smooth cross-app transitions.
203
220
  const version = initialPayload.metadata?.version;
221
+ const appShellRef = createAppShellRef({
222
+ routerId: initialPayload.metadata?.routerId,
223
+ rootLayout: initialPayload.metadata?.rootLayout,
224
+ basename: initialPayload.metadata?.basename,
225
+ version,
226
+ });
204
227
 
205
228
  // 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");
229
+ // The build version busts cached prefetches on deploy; the routerId
230
+ // namespaces the key so sibling apps on the same origin don't collide.
231
+ initRangoState(version ?? "0", initialPayload.metadata?.routerId);
232
+ setAppVersion(version);
208
233
 
209
234
  // Initialize the in-memory prefetch cache TTL from server config.
210
235
  // A value of 0 disables the cache; undefined falls back to the module default.
@@ -213,11 +238,17 @@ export async function initBrowserApp(
213
238
  initPrefetchCache(prefetchCacheTTL);
214
239
  }
215
240
 
216
- // Create a bound renderSegments that includes rootLayout
241
+ // Create a bound renderSegments that reads rootLayout through the shell
242
+ // ref. On app switch the ref is updated before the tree re-renders, so
243
+ // the new app's Document (rootLayout) replaces the previous one.
217
244
  const renderSegments = (
218
245
  segments: ResolvedSegment[],
219
246
  options?: RenderSegmentsOptions,
220
- ) => baseRenderSegments(segments, { ...options, rootLayout });
247
+ ) =>
248
+ baseRenderSegments(segments, {
249
+ ...options,
250
+ rootLayout: appShellRef.get().rootLayout,
251
+ });
221
252
 
222
253
  // Lazy reference for navigation bridge — the action bridge is created first
223
254
  // but may need to trigger SPA navigation for action redirects.
@@ -231,7 +262,6 @@ export async function initBrowserApp(
231
262
  deps,
232
263
  onUpdate: (update) => store.emitUpdate(update),
233
264
  renderSegments,
234
- version,
235
265
  onNavigate: (url, options) => {
236
266
  if (!navigateFn) {
237
267
  window.location.href = url;
@@ -249,7 +279,8 @@ export async function initBrowserApp(
249
279
  client,
250
280
  onUpdate: (update) => store.emitUpdate(update),
251
281
  renderSegments,
252
- version,
282
+ version: version,
283
+ appShellRef,
253
284
  });
254
285
 
255
286
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -263,75 +294,157 @@ export async function initBrowserApp(
263
294
  // Build initial tree with rootLayout
264
295
  const initialTree = renderSegments(initialPayload.metadata!.segments);
265
296
 
266
- // Setup HMR
297
+ // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
298
+ // fire many rsc:update events in quick succession. Without debouncing,
299
+ // each event triggers a fetchPartial() which on slow routes can pile up
300
+ // and overwhelm the worker (cross-request promise issues, 500s).
267
301
  if (import.meta.hot) {
268
- import.meta.hot.on("rsc:update", async () => {
269
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
270
-
271
- const handle = eventController.startNavigation(window.location.href, {
272
- replace: true,
273
- });
274
- const streamingToken = handle.startStreaming();
275
-
276
- const interceptSourceUrl = store.getInterceptSourceUrl();
277
-
278
- try {
279
- const { payload, streamComplete } = await client.fetchPartial({
280
- targetUrl: window.location.href,
281
- segmentIds: [],
282
- previousUrl: store.getSegmentState().currentUrl,
283
- interceptSourceUrl: interceptSourceUrl || undefined,
284
- hmr: true,
302
+ let hmrTimer: ReturnType<typeof setTimeout> | null = null;
303
+ let hmrAbort: AbortController | null = null;
304
+
305
+ import.meta.hot.on("rsc:update", () => {
306
+ // Cancel any pending debounce timer
307
+ if (hmrTimer !== null) {
308
+ clearTimeout(hmrTimer);
309
+ }
310
+
311
+ // Abort any in-flight HMR fetch so it doesn't race with the next one
312
+ if (hmrAbort) {
313
+ hmrAbort.abort();
314
+ hmrAbort = null;
315
+ }
316
+
317
+ // Debounce: wait 200ms of quiet before fetching
318
+ hmrTimer = setTimeout(async () => {
319
+ hmrTimer = null;
320
+
321
+ // Don't interrupt an active user navigation — startNavigation()
322
+ // would abort it and refetch the old URL (window.location.href
323
+ // hasn't updated yet). The user's navigation will pick up the
324
+ // new server code when it completes. isNavigating covers the
325
+ // full lifecycle (fetching + streaming, before commit) without
326
+ // blocking on server actions.
327
+ if (eventController.getState().isNavigating) {
328
+ console.log("[Rango] HMR: Skipping — navigation in progress");
329
+ return;
330
+ }
331
+
332
+ console.log("[Rango] HMR: Server update, refetching RSC");
333
+
334
+ const abort = new AbortController();
335
+ hmrAbort = abort;
336
+
337
+ const handle = eventController.startNavigation(window.location.href, {
338
+ replace: true,
285
339
  });
340
+ const streamingToken = handle.startStreaming();
341
+
342
+ const interceptSourceUrl = store.getInterceptSourceUrl();
343
+
344
+ try {
345
+ const { payload, streamComplete } = await client.fetchPartial({
346
+ targetUrl: window.location.href,
347
+ segmentIds: [],
348
+ previousUrl: store.getSegmentState().currentUrl,
349
+ interceptSourceUrl: interceptSourceUrl || undefined,
350
+ routerId: store.getRouterId?.(),
351
+ hmr: true,
352
+ signal: abort.signal,
353
+ });
286
354
 
287
- if (payload.metadata?.isPartial) {
288
- const segments = payload.metadata.segments || [];
289
- const matched = payload.metadata.matched || [];
355
+ if (abort.signal.aborted) return;
290
356
 
291
- // Derive intercept state from the returned payload, not the
292
- // pre-fetch store snapshot. If the HMR edit removed intercept
293
- // behavior, the response won't contain intercept segments.
294
- const responseIsIntercept = segments.some(isInterceptSegment);
357
+ // If the server returned a non-RSC response (404, 500 without
358
+ // error boundary), the payload won't have valid metadata.
359
+ // Reload to recover rather than leaving the page stale.
360
+ if (!payload.metadata) {
361
+ throw new Error("HMR refetch returned invalid payload");
362
+ }
295
363
 
296
- // Sync store intercept state with what the server returned
297
- if (!responseIsIntercept && interceptSourceUrl) {
298
- store.setInterceptSourceUrl(null);
364
+ // Update version BEFORE rebuilding state so that
365
+ // clearHistoryCache() runs first, then the fresh segment
366
+ // cache entry we create below survives.
367
+ //
368
+ // Compare against the bridge's live version, not the init-time
369
+ // `version` const: after the first HMR bump the const is stale, so a
370
+ // later update with an unchanged version would otherwise re-clear the
371
+ // cache and re-broadcast across tabs/apps. The live read fires only
372
+ // on a genuine version change.
373
+ const newVersion = payload.metadata.version;
374
+ const currentVersion = navigationBridge.getVersion();
375
+ if (newVersion && newVersion !== currentVersion) {
376
+ console.log(
377
+ "[Rango] HMR: version changed",
378
+ currentVersion,
379
+ "→",
380
+ newVersion,
381
+ "clearing caches",
382
+ );
383
+ navigationBridge.updateVersion(newVersion);
299
384
  }
300
385
 
301
- store.setSegmentIds(matched);
302
- store.setCurrentUrl(window.location.href);
386
+ // Apply only partial segment updates. A non-partial payload during
387
+ // HMR is transient: the worker route table is still rebuilding after
388
+ // the edit, so the URL momentarily resolves to not-found/catch-all.
389
+ // Skip it -- the debounced follow-up refetch returns the settled
390
+ // route's partial payload and renders it below. We never reload here:
391
+ // a paramless document GET would run the SSR path and surface the
392
+ // not-found page during that same transient.
393
+ if (payload.metadata?.isPartial) {
394
+ const segments = payload.metadata.segments || [];
395
+ const matched = payload.metadata.matched || [];
396
+
397
+ // Derive intercept state from the returned payload, not the
398
+ // pre-fetch store snapshot. If the HMR edit removed intercept
399
+ // behavior, the response won't contain intercept segments.
400
+ const responseIsIntercept = segments.some(isInterceptSegment);
401
+
402
+ // Sync store intercept state with what the server returned
403
+ if (!responseIsIntercept && interceptSourceUrl) {
404
+ store.setInterceptSourceUrl(null);
405
+ }
406
+
407
+ store.setSegmentIds(matched);
408
+ store.setCurrentUrl(window.location.href);
409
+
410
+ const historyKey = generateHistoryKey(window.location.href, {
411
+ intercept: responseIsIntercept,
412
+ });
413
+ store.setHistoryKey(historyKey);
414
+ const currentHandleData = eventController.getHandleState().data;
415
+ store.cacheSegmentsForHistory(
416
+ historyKey,
417
+ segments,
418
+ currentHandleData,
419
+ );
420
+
421
+ const { main, intercept } = splitInterceptSegments(segments);
422
+ store.emitUpdate({
423
+ root: renderSegments(main, {
424
+ interceptSegments: intercept.length > 0 ? intercept : undefined,
425
+ }),
426
+ metadata: payload.metadata,
427
+ });
428
+ }
303
429
 
304
- const historyKey = generateHistoryKey(window.location.href, {
305
- intercept: responseIsIntercept,
306
- });
307
- store.setHistoryKey(historyKey);
308
- const currentHandleData = eventController.getHandleState().data;
309
- store.cacheSegmentsForHistory(
310
- historyKey,
311
- segments,
312
- currentHandleData,
313
- );
314
-
315
- const { main, intercept } = splitInterceptSegments(segments);
316
- store.emitUpdate({
317
- root: renderSegments(main, {
318
- interceptSegments: intercept.length > 0 ? intercept : undefined,
319
- }),
320
- metadata: payload.metadata,
321
- });
430
+ await streamComplete;
431
+ handle.complete(new URL(window.location.href));
432
+ console.log("[Rango] HMR: RSC stream complete");
433
+ } catch (err) {
434
+ if (abort.signal.aborted) return;
435
+ console.warn("[Rango] HMR: Refetch failed, reloading page", err);
436
+ window.location.reload();
437
+ return;
438
+ } finally {
439
+ if (hmrAbort === abort) hmrAbort = null;
440
+ streamingToken.end();
441
+ handle[Symbol.dispose]();
322
442
  }
323
-
324
- await streamComplete;
325
- handle.complete(new URL(window.location.href));
326
- console.log("[RSCRouter] HMR: RSC stream complete");
327
- } finally {
328
- streamingToken.end();
329
- handle[Symbol.dispose]();
330
- }
443
+ }, 200);
331
444
  });
332
445
  }
333
446
 
334
- // Store context for RSCRouter component
447
+ // Store context for Rango component
335
448
  const context: BrowserAppContext = {
336
449
  store,
337
450
  eventController,
@@ -342,6 +455,7 @@ export async function initBrowserApp(
342
455
  initialTheme: effectiveInitialTheme,
343
456
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
344
457
  version,
458
+ appShellRef,
345
459
  };
346
460
  browserAppContext = context;
347
461
 
@@ -354,7 +468,7 @@ export async function initBrowserApp(
354
468
  export function getBrowserAppContext(): BrowserAppContext {
355
469
  if (!browserAppContext) {
356
470
  throw new Error(
357
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
471
+ "Rango: initBrowserApp() must be called before rendering Rango",
358
472
  );
359
473
  }
360
474
  return browserAppContext;
@@ -368,18 +482,18 @@ export function resetBrowserAppContext(): void {
368
482
  }
369
483
 
370
484
  /**
371
- * Props for the RSCRouter component
485
+ * Props for the Rango component
372
486
  */
373
- export interface RSCRouterProps {}
487
+ export interface RangoProps {}
374
488
 
375
489
  /**
376
- * RSCRouter component - renders the RSC router with all internal wiring.
490
+ * Rango component - renders the RSC router with all internal wiring.
377
491
  *
378
492
  * Must be called after initBrowserApp() has completed.
379
493
  *
380
494
  * @example
381
495
  * ```tsx
382
- * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
496
+ * import { initBrowserApp, Rango } from "rsc-router/browser";
383
497
  * import { rscStream } from "rsc-html-stream/client";
384
498
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
385
499
  *
@@ -389,14 +503,14 @@ export interface RSCRouterProps {}
389
503
  * hydrateRoot(
390
504
  * document,
391
505
  * <React.StrictMode>
392
- * <RSCRouter />
506
+ * <Rango />
393
507
  * </React.StrictMode>
394
508
  * );
395
509
  * }
396
510
  * main();
397
511
  * ```
398
512
  */
399
- export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
513
+ export function Rango(_props: RangoProps): React.ReactElement {
400
514
  const {
401
515
  store,
402
516
  eventController,
@@ -407,6 +521,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
407
521
  initialTheme,
408
522
  warmupEnabled,
409
523
  version,
524
+ appShellRef,
410
525
  } = getBrowserAppContext();
411
526
 
412
527
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -426,6 +541,8 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
426
541
  initialTheme={initialTheme}
427
542
  warmupEnabled={warmupEnabled}
428
543
  version={version}
544
+ basename={initialPayload.metadata?.basename}
545
+ appShellRef={appShellRef}
429
546
  />
430
547
  );
431
548
  }