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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2154 -861
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +220 -30
  11. package/skills/caching/SKILL.md +116 -8
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +71 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +243 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +57 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +128 -0
  37. package/skills/testing/bindings.md +89 -0
  38. package/skills/testing/cache-prerender.md +98 -0
  39. package/skills/testing/client-components.md +121 -0
  40. package/skills/testing/e2e-parity.md +124 -0
  41. package/skills/testing/flight.md +89 -0
  42. package/skills/testing/handles.md +127 -0
  43. package/skills/testing/loader.md +108 -0
  44. package/skills/testing/middleware.md +97 -0
  45. package/skills/testing/render-handler.md +102 -0
  46. package/skills/testing/response-routes.md +94 -0
  47. package/skills/testing/reverse-and-types.md +83 -0
  48. package/skills/testing/server-actions.md +89 -0
  49. package/skills/testing/server-tree.md +128 -0
  50. package/skills/testing/setup.md +120 -0
  51. package/skills/typesafety/SKILL.md +319 -27
  52. package/skills/use-cache/SKILL.md +34 -5
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/browser/action-coordinator.ts +53 -36
  57. package/src/browser/app-shell.ts +52 -0
  58. package/src/browser/event-controller.ts +86 -70
  59. package/src/browser/history-state.ts +21 -0
  60. package/src/browser/index.ts +3 -3
  61. package/src/browser/navigation-bridge.ts +84 -11
  62. package/src/browser/navigation-client.ts +104 -68
  63. package/src/browser/navigation-store.ts +32 -9
  64. package/src/browser/navigation-transaction.ts +10 -28
  65. package/src/browser/partial-update.ts +64 -26
  66. package/src/browser/prefetch/cache.ts +183 -44
  67. package/src/browser/prefetch/fetch.ts +228 -37
  68. package/src/browser/prefetch/queue.ts +36 -5
  69. package/src/browser/rango-state.ts +53 -13
  70. package/src/browser/react/Link.tsx +30 -2
  71. package/src/browser/react/NavigationProvider.tsx +72 -31
  72. package/src/browser/react/filter-segment-order.ts +51 -7
  73. package/src/browser/react/index.ts +3 -0
  74. package/src/browser/react/location-state-shared.ts +175 -4
  75. package/src/browser/react/location-state.ts +39 -13
  76. package/src/browser/react/use-handle.ts +17 -9
  77. package/src/browser/react/use-navigation.ts +22 -2
  78. package/src/browser/react/use-params.ts +20 -8
  79. package/src/browser/react/use-reverse.ts +106 -0
  80. package/src/browser/react/use-router.ts +22 -2
  81. package/src/browser/react/use-segments.ts +11 -8
  82. package/src/browser/response-adapter.ts +32 -1
  83. package/src/browser/rsc-router.tsx +69 -22
  84. package/src/browser/scroll-restoration.ts +22 -14
  85. package/src/browser/segment-reconciler.ts +36 -14
  86. package/src/browser/segment-structure-assert.ts +2 -2
  87. package/src/browser/server-action-bridge.ts +23 -30
  88. package/src/browser/types.ts +21 -0
  89. package/src/build/collect-fallback-refs.ts +107 -0
  90. package/src/build/generate-manifest.ts +60 -35
  91. package/src/build/generate-route-types.ts +2 -0
  92. package/src/build/index.ts +8 -1
  93. package/src/build/prefix-tree-utils.ts +123 -0
  94. package/src/build/route-trie.ts +95 -25
  95. package/src/build/route-types/codegen.ts +4 -4
  96. package/src/build/route-types/include-resolution.ts +1 -1
  97. package/src/build/route-types/per-module-writer.ts +7 -4
  98. package/src/build/route-types/router-processing.ts +55 -14
  99. package/src/build/route-types/scan-filter.ts +1 -1
  100. package/src/build/route-types/source-scan.ts +118 -0
  101. package/src/build/runtime-discovery.ts +9 -20
  102. package/src/cache/cache-scope.ts +28 -42
  103. package/src/cache/cf/cf-cache-store.ts +54 -13
  104. package/src/client.rsc.tsx +3 -0
  105. package/src/client.tsx +96 -205
  106. package/src/context-var.ts +5 -5
  107. package/src/decode-loader-results.ts +36 -0
  108. package/src/errors.ts +30 -4
  109. package/src/handle.ts +32 -14
  110. package/src/host/index.ts +2 -2
  111. package/src/host/router.ts +129 -57
  112. package/src/host/types.ts +31 -2
  113. package/src/host/utils.ts +1 -1
  114. package/src/href-client.ts +140 -21
  115. package/src/index.rsc.ts +10 -6
  116. package/src/index.ts +54 -17
  117. package/src/loader-store.ts +500 -0
  118. package/src/loader.rsc.ts +25 -7
  119. package/src/loader.ts +16 -9
  120. package/src/missing-id-error.ts +68 -0
  121. package/src/outlet-context.ts +1 -1
  122. package/src/prerender.ts +27 -6
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -36
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +384 -257
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +100 -28
  129. package/src/route-definition/resolve-handler-use.ts +6 -0
  130. package/src/route-definition/use-item-types.ts +32 -0
  131. package/src/route-types.ts +26 -41
  132. package/src/router/basename.ts +14 -0
  133. package/src/router/content-negotiation.ts +15 -2
  134. package/src/router/error-handling.ts +1 -1
  135. package/src/router/find-match.ts +54 -6
  136. package/src/router/handler-context.ts +21 -38
  137. package/src/router/intercept-resolution.ts +4 -18
  138. package/src/router/lazy-includes.ts +41 -22
  139. package/src/router/loader-resolution.ts +82 -36
  140. package/src/router/manifest.ts +41 -19
  141. package/src/router/match-api.ts +4 -3
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/cache-lookup.ts +44 -91
  144. package/src/router/match-middleware/cache-store.ts +3 -2
  145. package/src/router/match-result.ts +53 -32
  146. package/src/router/metrics.ts +1 -1
  147. package/src/router/middleware-types.ts +15 -26
  148. package/src/router/middleware.ts +99 -84
  149. package/src/router/pattern-matching.ts +116 -19
  150. package/src/router/prerender-match.ts +1 -1
  151. package/src/router/preview-match.ts +3 -1
  152. package/src/router/request-classification.ts +4 -28
  153. package/src/router/revalidation.ts +58 -2
  154. package/src/router/router-interfaces.ts +45 -28
  155. package/src/router/router-options.ts +40 -1
  156. package/src/router/router-registry.ts +2 -5
  157. package/src/router/segment-resolution/fresh.ts +27 -6
  158. package/src/router/segment-resolution/revalidation.ts +147 -106
  159. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  160. package/src/router/substitute-pattern-params.ts +56 -0
  161. package/src/router/telemetry.ts +99 -0
  162. package/src/router/trie-matching.ts +40 -16
  163. package/src/router/types.ts +8 -0
  164. package/src/router/url-params.ts +49 -0
  165. package/src/router.ts +52 -30
  166. package/src/rsc/handler-context.ts +2 -2
  167. package/src/rsc/handler.ts +28 -69
  168. package/src/rsc/helpers.ts +91 -43
  169. package/src/rsc/index.ts +1 -1
  170. package/src/rsc/manifest-init.ts +28 -41
  171. package/src/rsc/origin-guard.ts +28 -10
  172. package/src/rsc/progressive-enhancement.ts +4 -0
  173. package/src/rsc/response-error.ts +79 -12
  174. package/src/rsc/response-route-handler.ts +57 -61
  175. package/src/rsc/rsc-rendering.ts +35 -51
  176. package/src/rsc/runtime-warnings.ts +9 -10
  177. package/src/rsc/server-action.ts +17 -37
  178. package/src/rsc/ssr-setup.ts +16 -0
  179. package/src/rsc/types.ts +8 -2
  180. package/src/runtime-env.ts +18 -0
  181. package/src/search-params.ts +4 -4
  182. package/src/segment-content-promise.ts +67 -0
  183. package/src/segment-loader-promise.ts +122 -0
  184. package/src/segment-system.tsx +132 -116
  185. package/src/serialize.ts +243 -0
  186. package/src/server/context.ts +175 -53
  187. package/src/server/cookie-store.ts +28 -4
  188. package/src/server/request-context.ts +67 -51
  189. package/src/ssr/index.tsx +5 -1
  190. package/src/static-handler.ts +25 -3
  191. package/src/testing/cache-status.ts +166 -0
  192. package/src/testing/collect-handle.ts +63 -0
  193. package/src/testing/dispatch.ts +581 -0
  194. package/src/testing/dom.entry.ts +22 -0
  195. package/src/testing/e2e/fixture.ts +188 -0
  196. package/src/testing/e2e/index.ts +149 -0
  197. package/src/testing/e2e/matchers.ts +51 -0
  198. package/src/testing/e2e/page-helpers.ts +272 -0
  199. package/src/testing/e2e/parity.ts +326 -0
  200. package/src/testing/e2e/server.ts +195 -0
  201. package/src/testing/flight-matchers.ts +110 -0
  202. package/src/testing/flight-normalize.ts +38 -0
  203. package/src/testing/flight-runtime.d.ts +57 -0
  204. package/src/testing/flight-tree.ts +682 -0
  205. package/src/testing/flight.entry.ts +51 -0
  206. package/src/testing/flight.ts +234 -0
  207. package/src/testing/generated-routes.ts +223 -0
  208. package/src/testing/index.ts +106 -0
  209. package/src/testing/internal/context.ts +304 -0
  210. package/src/testing/internal/flight-client-globals.ts +30 -0
  211. package/src/testing/internal/seed-vars.ts +42 -0
  212. package/src/testing/render-handler.ts +323 -0
  213. package/src/testing/render-route.tsx +590 -0
  214. package/src/testing/run-loader.ts +363 -0
  215. package/src/testing/run-middleware.ts +205 -0
  216. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  217. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  218. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  219. package/src/testing/vitest-stubs/version.ts +5 -0
  220. package/src/testing/vitest.ts +285 -0
  221. package/src/types/global-namespace.ts +39 -26
  222. package/src/types/handler-context.ts +68 -50
  223. package/src/types/index.ts +1 -0
  224. package/src/types/loader-types.ts +11 -9
  225. package/src/types/request-scope.ts +126 -0
  226. package/src/types/route-entry.ts +11 -0
  227. package/src/types/segments.ts +35 -2
  228. package/src/urls/include-helper.ts +34 -67
  229. package/src/urls/index.ts +1 -5
  230. package/src/urls/path-helper-types.ts +41 -7
  231. package/src/urls/path-helper.ts +17 -52
  232. package/src/urls/pattern-types.ts +36 -19
  233. package/src/urls/response-types.ts +22 -29
  234. package/src/urls/type-extraction.ts +58 -139
  235. package/src/urls/urls-function.ts +1 -5
  236. package/src/use-loader.tsx +413 -42
  237. package/src/vite/debug.ts +185 -0
  238. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  239. package/src/vite/discovery/discover-routers.ts +106 -75
  240. package/src/vite/discovery/discovery-errors.ts +194 -0
  241. package/src/vite/discovery/gate-state.ts +171 -0
  242. package/src/vite/discovery/prerender-collection.ts +67 -26
  243. package/src/vite/discovery/route-types-writer.ts +40 -84
  244. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  245. package/src/vite/discovery/state.ts +33 -0
  246. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  247. package/src/vite/index.ts +2 -0
  248. package/src/vite/plugin-types.ts +67 -0
  249. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  250. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  251. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  252. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  253. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  254. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  255. package/src/vite/plugins/expose-action-id.ts +54 -30
  256. package/src/vite/plugins/expose-id-utils.ts +12 -8
  257. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  258. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  259. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  260. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  261. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  262. package/src/vite/plugins/performance-tracks.ts +29 -25
  263. package/src/vite/plugins/use-cache-transform.ts +65 -50
  264. package/src/vite/plugins/version-injector.ts +39 -23
  265. package/src/vite/plugins/version-plugin.ts +59 -2
  266. package/src/vite/plugins/virtual-entries.ts +2 -2
  267. package/src/vite/rango.ts +116 -29
  268. package/src/vite/router-discovery.ts +750 -100
  269. package/src/vite/utils/ast-handler-extract.ts +15 -15
  270. package/src/vite/utils/banner.ts +1 -1
  271. package/src/vite/utils/bundle-analysis.ts +4 -2
  272. package/src/vite/utils/client-chunks.ts +190 -0
  273. package/src/vite/utils/forward-user-plugins.ts +193 -0
  274. package/src/vite/utils/manifest-utils.ts +8 -59
  275. package/src/vite/utils/package-resolution.ts +41 -1
  276. package/src/vite/utils/prerender-utils.ts +21 -6
  277. package/src/vite/utils/shared-utils.ts +107 -26
  278. package/src/browser/action-response-classifier.ts +0 -99
@@ -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
@@ -31,11 +56,17 @@ export function emptyResponse(): Response {
31
56
  *
32
57
  * If the response has no body, onComplete fires synchronously.
33
58
  * If signal is provided, an abort cancels the tracking reader.
59
+ *
60
+ * `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
61
+ * low-priority prefetch that is aborted or never consumed can error its stream
62
+ * benignly, which is not worth surfacing. The fresh-navigation path keeps the
63
+ * log (default), where a stream error reflects a real failed navigation.
34
64
  */
35
65
  export function teeWithCompletion(
36
66
  response: Response,
37
67
  onComplete: () => void,
38
68
  signal?: AbortSignal,
69
+ silent = false,
39
70
  ): Response {
40
71
  if (!response.body) {
41
72
  onComplete();
@@ -59,7 +90,7 @@ export function teeWithCompletion(
59
90
  onComplete();
60
91
  }
61
92
  })().catch((error) => {
62
- if (!signal?.aborted) {
93
+ if (!silent && !signal?.aborted) {
63
94
  console.error("[Browser] Error reading tracking stream:", error);
64
95
  }
65
96
  onComplete();
@@ -23,11 +23,13 @@ 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 { setPrefetchDecoder } from "./prefetch/fetch.js";
26
27
  import { setAppVersion } from "./app-version.js";
27
28
  import {
28
29
  isInterceptSegment,
29
30
  splitInterceptSegments,
30
31
  } from "./intercept-utils.js";
32
+ import { createAppShellRef } from "./app-shell.js";
31
33
 
32
34
  // Vite HMR types are provided by vite/client
33
35
 
@@ -114,13 +116,20 @@ export interface BrowserAppContext {
114
116
  warmupEnabled?: boolean;
115
117
  /** App version for prefetch version mismatch detection */
116
118
  version?: string;
119
+ /**
120
+ * Live app-shell ref. Cross-app navigations replace its contents so the
121
+ * NavigationProvider and renderSegments pick up the target app's
122
+ * rootLayout, basename, and version without consumer rerenders. Theme,
123
+ * warmup, and prefetch TTL are document-lifetime (see AppShell).
124
+ */
125
+ appShellRef?: import("./app-shell.js").AppShellRef;
117
126
  }
118
127
 
119
128
  // Module-level state for the initialized app
120
129
  let browserAppContext: BrowserAppContext | null = null;
121
130
 
122
131
  /**
123
- * Initialize the browser app. Must be called before rendering RSCRouter.
132
+ * Initialize the browser app. Must be called before rendering Rango.
124
133
  *
125
134
  * This function:
126
135
  * - Loads the initial RSC payload from the stream
@@ -204,13 +213,23 @@ export async function initBrowserApp(
204
213
  // Create composable utilities
205
214
  const client = createNavigationClient(deps);
206
215
 
207
- // Extract rootLayout and version from metadata for browser-side re-renders
208
- const rootLayout = initialPayload.metadata?.rootLayout;
216
+ // Capture the per-router app-shell so cross-app navigations can replace
217
+ // it atomically. rootLayout, basename, and version live here and are
218
+ // read through the ref at call time rather than closed over. Theme,
219
+ // warmup, and prefetch TTL are deliberately excluded — they are
220
+ // document-lifetime and stay stable across smooth cross-app transitions.
209
221
  const version = initialPayload.metadata?.version;
222
+ const appShellRef = createAppShellRef({
223
+ routerId: initialPayload.metadata?.routerId,
224
+ rootLayout: initialPayload.metadata?.rootLayout,
225
+ basename: initialPayload.metadata?.basename,
226
+ version,
227
+ });
210
228
 
211
229
  // Initialize the localStorage state key for cache invalidation.
212
- // Uses the build version so a new deploy automatically busts all cached prefetches.
213
- initRangoState(version ?? "0");
230
+ // The build version busts cached prefetches on deploy; the routerId
231
+ // namespaces the key so sibling apps on the same origin don't collide.
232
+ initRangoState(version ?? "0", initialPayload.metadata?.routerId);
214
233
  setAppVersion(version);
215
234
 
216
235
  // Initialize the in-memory prefetch cache TTL from server config.
@@ -220,11 +239,21 @@ export async function initBrowserApp(
220
239
  initPrefetchCache(prefetchCacheTTL);
221
240
  }
222
241
 
223
- // Create a bound renderSegments that includes rootLayout
242
+ // Wire the RSC decoder so prefetches decode eagerly and warm the route's
243
+ // client chunks (same createFromFetch the navigation client uses).
244
+ setPrefetchDecoder((response) => deps.createFromFetch<RscPayload>(response));
245
+
246
+ // Create a bound renderSegments that reads rootLayout through the shell
247
+ // ref. On app switch the ref is updated before the tree re-renders, so
248
+ // the new app's Document (rootLayout) replaces the previous one.
224
249
  const renderSegments = (
225
250
  segments: ResolvedSegment[],
226
251
  options?: RenderSegmentsOptions,
227
- ) => baseRenderSegments(segments, { ...options, rootLayout });
252
+ ) =>
253
+ baseRenderSegments(segments, {
254
+ ...options,
255
+ rootLayout: appShellRef.get().rootLayout,
256
+ });
228
257
 
229
258
  // Lazy reference for navigation bridge — the action bridge is created first
230
259
  // but may need to trigger SPA navigation for action redirects.
@@ -256,6 +285,7 @@ export async function initBrowserApp(
256
285
  onUpdate: (update) => store.emitUpdate(update),
257
286
  renderSegments,
258
287
  version: version,
288
+ appShellRef,
259
289
  });
260
290
 
261
291
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -300,11 +330,11 @@ export async function initBrowserApp(
300
330
  // full lifecycle (fetching + streaming, before commit) without
301
331
  // blocking on server actions.
302
332
  if (eventController.getState().isNavigating) {
303
- console.log("[RSCRouter] HMR: Skipping — navigation in progress");
333
+ console.log("[Rango] HMR: Skipping — navigation in progress");
304
334
  return;
305
335
  }
306
336
 
307
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
337
+ console.log("[Rango] HMR: Server update, refetching RSC");
308
338
 
309
339
  const abort = new AbortController();
310
340
  hmrAbort = abort;
@@ -339,11 +369,18 @@ export async function initBrowserApp(
339
369
  // Update version BEFORE rebuilding state so that
340
370
  // clearHistoryCache() runs first, then the fresh segment
341
371
  // cache entry we create below survives.
372
+ //
373
+ // Compare against the bridge's live version, not the init-time
374
+ // `version` const: after the first HMR bump the const is stale, so a
375
+ // later update with an unchanged version would otherwise re-clear the
376
+ // cache and re-broadcast across tabs/apps. The live read fires only
377
+ // on a genuine version change.
342
378
  const newVersion = payload.metadata.version;
343
- if (newVersion && newVersion !== version) {
379
+ const currentVersion = navigationBridge.getVersion();
380
+ if (newVersion && newVersion !== currentVersion) {
344
381
  console.log(
345
- "[RSCRouter] HMR: version changed",
346
- version,
382
+ "[Rango] HMR: version changed",
383
+ currentVersion,
347
384
  "→",
348
385
  newVersion,
349
386
  "clearing caches",
@@ -351,6 +388,13 @@ export async function initBrowserApp(
351
388
  navigationBridge.updateVersion(newVersion);
352
389
  }
353
390
 
391
+ // Apply only partial segment updates. A non-partial payload during
392
+ // HMR is transient: the worker route table is still rebuilding after
393
+ // the edit, so the URL momentarily resolves to not-found/catch-all.
394
+ // Skip it -- the debounced follow-up refetch returns the settled
395
+ // route's partial payload and renders it below. We never reload here:
396
+ // a paramless document GET would run the SSR path and surface the
397
+ // not-found page during that same transient.
354
398
  if (payload.metadata?.isPartial) {
355
399
  const segments = payload.metadata.segments || [];
356
400
  const matched = payload.metadata.matched || [];
@@ -390,10 +434,10 @@ export async function initBrowserApp(
390
434
 
391
435
  await streamComplete;
392
436
  handle.complete(new URL(window.location.href));
393
- console.log("[RSCRouter] HMR: RSC stream complete");
437
+ console.log("[Rango] HMR: RSC stream complete");
394
438
  } catch (err) {
395
439
  if (abort.signal.aborted) return;
396
- console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
440
+ console.warn("[Rango] HMR: Refetch failed, reloading page", err);
397
441
  window.location.reload();
398
442
  return;
399
443
  } finally {
@@ -405,7 +449,7 @@ export async function initBrowserApp(
405
449
  });
406
450
  }
407
451
 
408
- // Store context for RSCRouter component
452
+ // Store context for Rango component
409
453
  const context: BrowserAppContext = {
410
454
  store,
411
455
  eventController,
@@ -416,6 +460,7 @@ export async function initBrowserApp(
416
460
  initialTheme: effectiveInitialTheme,
417
461
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
418
462
  version,
463
+ appShellRef,
419
464
  };
420
465
  browserAppContext = context;
421
466
 
@@ -428,7 +473,7 @@ export async function initBrowserApp(
428
473
  export function getBrowserAppContext(): BrowserAppContext {
429
474
  if (!browserAppContext) {
430
475
  throw new Error(
431
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
476
+ "Rango: initBrowserApp() must be called before rendering Rango",
432
477
  );
433
478
  }
434
479
  return browserAppContext;
@@ -442,18 +487,18 @@ export function resetBrowserAppContext(): void {
442
487
  }
443
488
 
444
489
  /**
445
- * Props for the RSCRouter component
490
+ * Props for the Rango component
446
491
  */
447
- export interface RSCRouterProps {}
492
+ export interface RangoProps {}
448
493
 
449
494
  /**
450
- * RSCRouter component - renders the RSC router with all internal wiring.
495
+ * Rango component - renders the RSC router with all internal wiring.
451
496
  *
452
497
  * Must be called after initBrowserApp() has completed.
453
498
  *
454
499
  * @example
455
500
  * ```tsx
456
- * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
501
+ * import { initBrowserApp, Rango } from "rsc-router/browser";
457
502
  * import { rscStream } from "rsc-html-stream/client";
458
503
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
459
504
  *
@@ -463,14 +508,14 @@ export interface RSCRouterProps {}
463
508
  * hydrateRoot(
464
509
  * document,
465
510
  * <React.StrictMode>
466
- * <RSCRouter />
511
+ * <Rango />
467
512
  * </React.StrictMode>
468
513
  * );
469
514
  * }
470
515
  * main();
471
516
  * ```
472
517
  */
473
- export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
518
+ export function Rango(_props: RangoProps): React.ReactElement {
474
519
  const {
475
520
  store,
476
521
  eventController,
@@ -481,6 +526,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
481
526
  initialTheme,
482
527
  warmupEnabled,
483
528
  version,
529
+ appShellRef,
484
530
  } = getBrowserAppContext();
485
531
 
486
532
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -501,6 +547,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
501
547
  warmupEnabled={warmupEnabled}
502
548
  version={version}
503
549
  basename={initialPayload.metadata?.basename}
550
+ appShellRef={appShellRef}
504
551
  />
505
552
  );
506
553
  }
@@ -332,6 +332,8 @@ export function scrollToHash(): boolean {
332
332
  * Scroll to top of page
333
333
  */
334
334
  export function scrollToTop(): void {
335
+ if (typeof window === "undefined") return;
336
+ if (typeof window.scrollTo !== "function") return;
335
337
  window.scrollTo(0, 0);
336
338
  }
337
339
 
@@ -374,20 +376,26 @@ export function handleNavigationEnd(options: {
374
376
  // Fall through to hash or top if no saved position
375
377
  }
376
378
 
377
- // Defer hash and scroll-to-top to after React paints the new content,
378
- // so the user doesn't see the current page jump before the new route appears.
379
- deferToNextPaint(() => {
380
- // Re-check: the deferred callback may fire after environment teardown
381
- if (typeof window === "undefined") return;
382
-
383
- // Try hash scrolling first
384
- if (scrollToHash()) {
385
- return;
386
- }
387
-
388
- // Default: scroll to top
389
- scrollToTop();
390
- });
379
+ // scrollToHash / scrollToTop run synchronously here.
380
+ // handleNavigationEnd is invoked from NavigationProvider's
381
+ // useLayoutEffect (post-commit, pre-paint), so a sync scrollTo is
382
+ // captured by the upcoming paint AND by startViewTransition's snapshot.
383
+ // Deferring via rAF here pushed the call past the snapshot capture,
384
+ // making forward navigations wrapped in a layout/route view transition
385
+ // skip scroll-to-top the live DOM scrolled but the captured snapshot
386
+ // was at the previous scroll position, so the user-facing page stayed
387
+ // visually clamped at the source page's scrollY (often the new tree's
388
+ // max scroll for tall→short navs). Y=0 / a hash element are robust
389
+ // against unmeasured layout, so sync scroll is correct here even
390
+ // before the new tree's scrollHeight settles.
391
+ //
392
+ // (The restore branch above keeps deferToNextPaint because savedY
393
+ // depends on the new tree's max scroll; sync scrollTo against an
394
+ // unmeasured DOM would clamp savedY to whatever the old/zero max was.)
395
+ if (scrollToHash()) {
396
+ return;
397
+ }
398
+ scrollToTop();
391
399
  }
392
400
 
393
401
  /**
@@ -6,6 +6,7 @@ import {
6
6
  } from "./merge-segment-loaders.js";
7
7
  import { assertSegmentStructure } from "./segment-structure-assert.js";
8
8
  import { splitInterceptSegments } from "./intercept-utils.js";
9
+ import { debugLog } from "./logging.js";
9
10
 
10
11
  /**
11
12
  * Determines the merging behavior for segment reconciliation.
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
85
86
  const cachedSegments = new Map<string, ResolvedSegment>();
86
87
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
87
88
 
89
+ const diffSet = new Set(diff);
90
+ debugLog(
91
+ `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
92
+ );
93
+ debugLog(
94
+ `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
95
+ );
96
+ debugLog(
97
+ `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
98
+ );
99
+
88
100
  const segments = matched
89
101
  .map((segId: string) => {
90
102
  const fromServer = serverSegments.get(segId);
91
103
  const fromCache = cachedSegments.get(segId);
92
104
 
93
105
  if (fromServer) {
106
+ const inDiff = diffSet.has(segId);
94
107
  // Merge partial loader data when server returns fewer loaders than cached
95
108
  if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
109
+ debugLog(
110
+ `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
111
+ );
96
112
  return mergeSegmentLoaders(fromServer, fromCache);
97
113
  }
98
114
 
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
143
159
  // above fails to preserve a value it should have.
144
160
  assertSegmentStructure(fromCache, merged, context);
145
161
 
162
+ debugLog(
163
+ `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
164
+ );
146
165
  return merged;
147
166
  }
167
+ debugLog(
168
+ `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
169
+ );
148
170
  return fromServer;
149
171
  }
150
172
 
@@ -158,20 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
158
180
  return fromCache;
159
181
  }
160
182
 
161
- // For non-action actors: cached segments the server decided not to re-render.
162
- // - Preserve loading=false (suppressed boundary) to maintain tree structure
163
- // - Preserve parallel segment loading so renderSegments can reconstruct
164
- // parallel-owned loader markers from the cached slot metadata
165
- // - Clear other truthy loading values to prevent suspense on cached content
166
- if (actor !== "action") {
167
- if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
168
- return fromCache;
169
- }
170
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
171
- return { ...fromCache, loading: undefined };
172
- }
173
- }
174
-
183
+ debugLog(
184
+ `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
185
+ );
186
+
187
+ // Return the cached segment as-is, regardless of actor. We used to clear
188
+ // truthy `loading` here to prevent a stale Suspense fallback from
189
+ // committing against cached content, but that swapped the render tree
190
+ // from the LoaderBoundary branch to the plain OutletProvider branch
191
+ // inside renderSegments, causing React to unmount the entire chain
192
+ // (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
193
+ // Suspender) every time the user opened an intercept or navigated back
194
+ // to a cached page. The flicker is now prevented by renderSegments'
195
+ // promise memoization keeping React's use() in "known fulfilled" state,
196
+ // so preserving `loading` keeps the element tree stable.
175
197
  return fromCache;
176
198
  })
177
199
  .filter(Boolean) as ResolvedSegment[];
@@ -48,7 +48,7 @@ export function assertSegmentStructure(
48
48
 
49
49
  if (cachedCategory !== incomingCategory) {
50
50
  console.warn(
51
- `[RSC Router] Tree structure mismatch detected in ${context} ` +
51
+ `[Rango] Tree structure mismatch detected in ${context} ` +
52
52
  `for segment "${cached.id}": loading category changed from ` +
53
53
  `"${cachedCategory}" (${describeLoading(cached.loading)}) to ` +
54
54
  `"${incomingCategory}" (${describeLoading(incoming.loading)}). ` +
@@ -64,7 +64,7 @@ export function assertSegmentStructure(
64
64
  const incomingHasMount = !!incoming.mountPath;
65
65
  if (cachedHasMount !== incomingHasMount) {
66
66
  console.warn(
67
- `[RSC Router] MountContextProvider mismatch detected in ${context} ` +
67
+ `[Rango] MountContextProvider mismatch detected in ${context} ` +
68
68
  `for segment "${cached.id}": mountPath changed from ` +
69
69
  `${cachedHasMount ? `"${cached.mountPath}"` : "undefined"} to ` +
70
70
  `${incomingHasMount ? `"${incoming.mountPath}"` : "undefined"}. ` +
@@ -25,6 +25,7 @@ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
25
25
  import {
26
26
  extractRscHeaderUrl,
27
27
  emptyResponse,
28
+ handleReloadHeader,
28
29
  teeWithCompletion,
29
30
  } from "./response-adapter.js";
30
31
  import { mergeLocationState } from "./history-state.js";
@@ -77,6 +78,20 @@ export function createServerActionBridge(
77
78
  onNavigate,
78
79
  } = config;
79
80
 
81
+ // SPA-navigate when onNavigate is set, else hard-reload. state is omitted (not
82
+ // passed as undefined) to match the header path's prior call shape.
83
+ async function dispatchRedirect(url: string, state?: unknown): Promise<void> {
84
+ if (onNavigate) {
85
+ await onNavigate(url, {
86
+ ...(state !== undefined ? { state } : {}),
87
+ replace: true,
88
+ _skipCache: true,
89
+ });
90
+ } else {
91
+ window.location.href = url;
92
+ }
93
+ }
94
+
80
95
  let isRegistered = false;
81
96
 
82
97
  const fetchPartialUpdate = createPartialUpdater({
@@ -222,18 +237,12 @@ export function createServerActionBridge(
222
237
  handle.signal.removeEventListener("abort", onHandleAbort);
223
238
 
224
239
  // Check for version mismatch - server wants us to reload
225
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
226
- if (reload === "blocked") {
227
- resolveStreamComplete();
228
- return emptyResponse();
229
- }
230
- if (reload) {
231
- log("version mismatch on action, reloading", {
232
- reloadUrl: reload.url,
233
- });
234
- window.location.href = reload.url;
235
- return new Promise<Response>(() => {});
236
- }
240
+ const reloadResult = handleReloadHeader(response, {
241
+ onBlocked: resolveStreamComplete,
242
+ onReload: (url) =>
243
+ log("version mismatch on action, reloading", { reloadUrl: url }),
244
+ });
245
+ if (reloadResult) return reloadResult;
237
246
 
238
247
  // Simple redirect from action (no state, no RSC payload).
239
248
  // Short-circuits before createFromFetch — no Flight deserialization needed.
@@ -243,14 +252,7 @@ export function createServerActionBridge(
243
252
  if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
244
253
  log("action simple redirect", { url: redirect.url });
245
254
  handle.complete(undefined);
246
- if (onNavigate) {
247
- await onNavigate(redirect.url, {
248
- replace: true,
249
- _skipCache: true,
250
- });
251
- } else {
252
- window.location.href = redirect.url;
253
- }
255
+ await dispatchRedirect(redirect.url);
254
256
  return new Promise<Response>(() => {});
255
257
  }
256
258
  if (redirect === "blocked") {
@@ -339,18 +341,9 @@ export function createServerActionBridge(
339
341
  handle.complete(returnValue?.data);
340
342
  return returnValue?.data;
341
343
  }
342
- const redirectState = metadata.locationState;
343
344
  log("action redirect", { url: redirectUrl });
344
345
  handle.complete(returnValue?.data);
345
- if (onNavigate) {
346
- await onNavigate(redirectUrl, {
347
- state: redirectState,
348
- replace: true,
349
- _skipCache: true,
350
- });
351
- } else {
352
- window.location.href = redirectUrl;
353
- }
346
+ await dispatchRedirect(redirectUrl, metadata.locationState);
354
347
  return returnValue?.data;
355
348
  }
356
349
 
@@ -39,6 +39,12 @@ export interface RscMetadata {
39
39
  isError?: boolean;
40
40
  matched?: string[];
41
41
  diff?: string[];
42
+ /**
43
+ * All segment ids re-resolved on the server, including null-component
44
+ * ones excluded from `segments`/`diff`. Drives client-side handle-bucket
45
+ * cleanup. Superset of `diff`. See MatchResult.resolvedIds.
46
+ */
47
+ resolvedIds?: string[];
42
48
  /** Merged route params from the matched route */
43
49
  params?: Record<string, string>;
44
50
  /**
@@ -427,6 +433,12 @@ export interface NavigationStore {
427
433
  markCacheAsStale(): void;
428
434
  markCacheAsStaleAndBroadcast(): void;
429
435
  clearHistoryCache(): void;
436
+ /**
437
+ * Clear this tab's nav + prefetch caches without broadcasting or rotating
438
+ * shared state. Intended for app-switch transitions that affect only this
439
+ * tab's session.
440
+ */
441
+ clearHistoryCacheLocal(): void;
430
442
  broadcastCacheInvalidation(): void;
431
443
 
432
444
  // Cross-tab refresh callback (set by navigation bridge)
@@ -540,8 +552,17 @@ export interface NavigationBridge {
540
552
  refresh(): Promise<void>;
541
553
  handlePopstate(): Promise<void>;
542
554
  registerLinkInterception(): () => void;
555
+ /** Current RSC version (live, reflects the latest updateVersion). */
556
+ getVersion(): string | undefined;
543
557
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
544
558
  updateVersion(newVersion: string): void;
559
+ /**
560
+ * Replace the active app-shell snapshot (rootLayout, basename, version)
561
+ * atomically. Used on cross-app navigations when the response's routerId
562
+ * indicates the user entered a different app. Theme, warmup, and prefetch
563
+ * TTL are document-lifetime and not part of the shell.
564
+ */
565
+ updateAppShell(next: import("./app-shell.js").AppShell): void;
545
566
  }
546
567
 
547
568
  /**