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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +242 -55
  3. package/dist/bin/rango.js +277 -99
  4. package/dist/vite/index.js +2929 -1132
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +68 -21
  8. package/skills/breadcrumbs/SKILL.md +252 -0
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +159 -10
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +262 -51
  16. package/skills/host-router/SKILL.md +243 -0
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +249 -17
  21. package/skills/loader/SKILL.md +291 -31
  22. package/skills/middleware/SKILL.md +49 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +197 -6
  28. package/skills/prerender/SKILL.md +125 -102
  29. package/skills/rango/SKILL.md +242 -23
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +91 -8
  33. package/skills/router-setup/SKILL.md +98 -8
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +511 -188
  37. package/skills/typesafety/SKILL.md +354 -50
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +117 -0
  42. package/src/__internal.ts +92 -0
  43. package/src/browser/action-coordinator.ts +53 -36
  44. package/src/browser/app-shell.ts +52 -0
  45. package/src/browser/app-version.ts +14 -0
  46. package/src/browser/event-controller.ts +91 -70
  47. package/src/browser/history-state.ts +21 -0
  48. package/src/browser/index.ts +3 -3
  49. package/src/browser/link-interceptor.ts +4 -0
  50. package/src/browser/navigation-bridge.ts +183 -18
  51. package/src/browser/navigation-client.ts +187 -57
  52. package/src/browser/navigation-store.ts +75 -17
  53. package/src/browser/navigation-transaction.ts +21 -37
  54. package/src/browser/partial-update.ts +143 -40
  55. package/src/browser/prefetch/cache.ts +275 -28
  56. package/src/browser/prefetch/fetch.ts +191 -46
  57. package/src/browser/prefetch/policy.ts +6 -0
  58. package/src/browser/prefetch/queue.ts +123 -20
  59. package/src/browser/prefetch/resource-ready.ts +77 -0
  60. package/src/browser/rango-state.ts +53 -13
  61. package/src/browser/react/Link.tsx +98 -14
  62. package/src/browser/react/NavigationProvider.tsx +110 -33
  63. package/src/browser/react/context.ts +7 -2
  64. package/src/browser/react/filter-segment-order.ts +51 -7
  65. package/src/browser/react/index.ts +3 -0
  66. package/src/browser/react/location-state-shared.ts +175 -4
  67. package/src/browser/react/location-state.ts +39 -13
  68. package/src/browser/react/use-handle.ts +23 -64
  69. package/src/browser/react/use-navigation.ts +22 -2
  70. package/src/browser/react/use-params.ts +20 -8
  71. package/src/browser/react/use-reverse.ts +106 -0
  72. package/src/browser/react/use-router.ts +43 -10
  73. package/src/browser/react/use-segments.ts +11 -8
  74. package/src/browser/response-adapter.ts +25 -0
  75. package/src/browser/rsc-router.tsx +200 -75
  76. package/src/browser/scroll-restoration.ts +46 -39
  77. package/src/browser/segment-reconciler.ts +36 -9
  78. package/src/browser/segment-structure-assert.ts +2 -2
  79. package/src/browser/server-action-bridge.ts +31 -36
  80. package/src/browser/types.ts +81 -5
  81. package/src/build/collect-fallback-refs.ts +107 -0
  82. package/src/build/generate-manifest.ts +65 -40
  83. package/src/build/generate-route-types.ts +5 -0
  84. package/src/build/index.ts +2 -0
  85. package/src/build/route-trie.ts +69 -26
  86. package/src/build/route-types/codegen.ts +4 -4
  87. package/src/build/route-types/include-resolution.ts +9 -2
  88. package/src/build/route-types/per-module-writer.ts +7 -4
  89. package/src/build/route-types/router-processing.ts +278 -88
  90. package/src/build/route-types/scan-filter.ts +9 -2
  91. package/src/build/route-types/source-scan.ts +118 -0
  92. package/src/build/runtime-discovery.ts +9 -20
  93. package/src/cache/cache-runtime.ts +15 -11
  94. package/src/cache/cache-scope.ts +76 -49
  95. package/src/cache/cf/cf-cache-store.ts +501 -18
  96. package/src/cache/cf/index.ts +5 -1
  97. package/src/cache/document-cache.ts +17 -7
  98. package/src/cache/index.ts +1 -0
  99. package/src/cache/taint.ts +55 -0
  100. package/src/client.rsc.tsx +5 -1
  101. package/src/client.tsx +95 -284
  102. package/src/context-var.ts +72 -2
  103. package/src/debug.ts +2 -2
  104. package/src/decode-loader-results.ts +36 -0
  105. package/src/errors.ts +30 -1
  106. package/src/handle.ts +65 -12
  107. package/src/handles/breadcrumbs.ts +66 -0
  108. package/src/handles/index.ts +1 -0
  109. package/src/host/index.ts +2 -5
  110. package/src/host/router.ts +129 -57
  111. package/src/host/types.ts +31 -2
  112. package/src/host/utils.ts +1 -1
  113. package/src/href-client.ts +140 -20
  114. package/src/index.rsc.ts +15 -40
  115. package/src/index.ts +92 -76
  116. package/src/loader-store.ts +500 -0
  117. package/src/loader.rsc.ts +2 -5
  118. package/src/loader.ts +3 -10
  119. package/src/missing-id-error.ts +68 -0
  120. package/src/outlet-context.ts +1 -1
  121. package/src/prerender/store.ts +57 -15
  122. package/src/prerender.ts +141 -80
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -15
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +435 -260
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +110 -34
  129. package/src/route-definition/index.ts +3 -3
  130. package/src/route-definition/redirect.ts +11 -3
  131. package/src/route-definition/resolve-handler-use.ts +155 -0
  132. package/src/route-definition/use-item-types.ts +32 -0
  133. package/src/route-map-builder.ts +7 -1
  134. package/src/route-types.ts +37 -41
  135. package/src/router/basename.ts +14 -0
  136. package/src/router/content-negotiation.ts +113 -1
  137. package/src/router/error-handling.ts +1 -1
  138. package/src/router/find-match.ts +4 -2
  139. package/src/router/handler-context.ts +105 -39
  140. package/src/router/intercept-resolution.ts +15 -22
  141. package/src/router/lazy-includes.ts +12 -9
  142. package/src/router/loader-resolution.ts +175 -23
  143. package/src/router/logging.ts +5 -2
  144. package/src/router/manifest.ts +31 -16
  145. package/src/router/match-api.ts +129 -193
  146. package/src/router/match-handlers.ts +63 -20
  147. package/src/router/match-middleware/background-revalidation.ts +30 -2
  148. package/src/router/match-middleware/cache-lookup.ts +136 -106
  149. package/src/router/match-middleware/cache-store.ts +54 -10
  150. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  151. package/src/router/match-middleware/segment-resolution.ts +61 -5
  152. package/src/router/match-result.ts +124 -18
  153. package/src/router/metrics.ts +239 -14
  154. package/src/router/middleware-types.ts +61 -31
  155. package/src/router/middleware.ts +226 -124
  156. package/src/router/navigation-snapshot.ts +182 -0
  157. package/src/router/pattern-matching.ts +118 -19
  158. package/src/router/prerender-match.ts +114 -10
  159. package/src/router/preview-match.ts +32 -102
  160. package/src/router/request-classification.ts +286 -0
  161. package/src/router/revalidation.ts +85 -9
  162. package/src/router/route-snapshot.ts +245 -0
  163. package/src/router/router-context.ts +6 -1
  164. package/src/router/router-interfaces.ts +91 -29
  165. package/src/router/router-options.ts +89 -19
  166. package/src/router/router-registry.ts +2 -5
  167. package/src/router/segment-resolution/fresh.ts +240 -23
  168. package/src/router/segment-resolution/helpers.ts +30 -25
  169. package/src/router/segment-resolution/loader-cache.ts +1 -0
  170. package/src/router/segment-resolution/revalidation.ts +483 -289
  171. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  172. package/src/router/segment-wrappers.ts +2 -0
  173. package/src/router/substitute-pattern-params.ts +56 -0
  174. package/src/router/telemetry.ts +99 -0
  175. package/src/router/trie-matching.ts +38 -15
  176. package/src/router/types.ts +9 -0
  177. package/src/router/url-params.ts +49 -0
  178. package/src/router.ts +120 -32
  179. package/src/rsc/handler-context.ts +2 -2
  180. package/src/rsc/handler.ts +524 -370
  181. package/src/rsc/helpers.ts +91 -43
  182. package/src/rsc/index.ts +1 -21
  183. package/src/rsc/loader-fetch.ts +23 -3
  184. package/src/rsc/manifest-init.ts +5 -1
  185. package/src/rsc/origin-guard.ts +28 -10
  186. package/src/rsc/progressive-enhancement.ts +39 -10
  187. package/src/rsc/response-route-handler.ts +46 -53
  188. package/src/rsc/rsc-rendering.ts +69 -89
  189. package/src/rsc/runtime-warnings.ts +9 -10
  190. package/src/rsc/server-action.ts +39 -47
  191. package/src/rsc/ssr-setup.ts +144 -0
  192. package/src/rsc/types.ts +19 -3
  193. package/src/search-params.ts +20 -17
  194. package/src/segment-content-promise.ts +67 -0
  195. package/src/segment-loader-promise.ts +122 -0
  196. package/src/segment-system.tsx +219 -67
  197. package/src/serialize.ts +243 -0
  198. package/src/server/context.ts +285 -63
  199. package/src/server/cookie-store.ts +28 -4
  200. package/src/server/handle-store.ts +19 -0
  201. package/src/server/loader-registry.ts +9 -8
  202. package/src/server/request-context.ts +228 -65
  203. package/src/server.ts +6 -0
  204. package/src/ssr/index.tsx +9 -1
  205. package/src/static-handler.ts +19 -7
  206. package/src/testing/cache-status.ts +166 -0
  207. package/src/testing/collect-handle.ts +63 -0
  208. package/src/testing/dispatch.ts +440 -0
  209. package/src/testing/dom.entry.ts +22 -0
  210. package/src/testing/e2e/fixture.ts +154 -0
  211. package/src/testing/e2e/index.ts +149 -0
  212. package/src/testing/e2e/matchers.ts +51 -0
  213. package/src/testing/e2e/page-helpers.ts +272 -0
  214. package/src/testing/e2e/parity.ts +306 -0
  215. package/src/testing/e2e/server.ts +183 -0
  216. package/src/testing/flight-matchers.ts +104 -0
  217. package/src/testing/flight-runtime.d.ts +21 -0
  218. package/src/testing/flight.entry.ts +22 -0
  219. package/src/testing/flight.ts +182 -0
  220. package/src/testing/generated-routes.ts +223 -0
  221. package/src/testing/index.ts +98 -0
  222. package/src/testing/internal/context.ts +151 -0
  223. package/src/testing/render-route.tsx +536 -0
  224. package/src/testing/run-loader.ts +296 -0
  225. package/src/testing/run-middleware.ts +170 -0
  226. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  227. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  228. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  229. package/src/testing/vitest-stubs/version.ts +5 -0
  230. package/src/testing/vitest.ts +112 -0
  231. package/src/theme/index.ts +4 -13
  232. package/src/types/cache-types.ts +4 -4
  233. package/src/types/global-namespace.ts +39 -26
  234. package/src/types/handler-context.ts +197 -79
  235. package/src/types/index.ts +1 -0
  236. package/src/types/loader-types.ts +41 -15
  237. package/src/types/request-scope.ts +126 -0
  238. package/src/types/route-config.ts +17 -8
  239. package/src/types/route-entry.ts +19 -1
  240. package/src/types/segments.ts +37 -6
  241. package/src/urls/include-helper.ts +34 -67
  242. package/src/urls/index.ts +0 -3
  243. package/src/urls/path-helper-types.ts +50 -9
  244. package/src/urls/path-helper.ts +63 -63
  245. package/src/urls/pattern-types.ts +48 -19
  246. package/src/urls/response-types.ts +25 -22
  247. package/src/urls/type-extraction.ts +26 -116
  248. package/src/urls/urls-function.ts +1 -5
  249. package/src/use-loader.tsx +487 -44
  250. package/src/vite/debug.ts +185 -0
  251. package/src/vite/discovery/bundle-postprocess.ts +63 -91
  252. package/src/vite/discovery/discover-routers.ts +106 -53
  253. package/src/vite/discovery/discovery-errors.ts +194 -0
  254. package/src/vite/discovery/gate-state.ts +171 -0
  255. package/src/vite/discovery/prerender-collection.ts +222 -107
  256. package/src/vite/discovery/route-types-writer.ts +40 -84
  257. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  258. package/src/vite/discovery/state.ts +50 -13
  259. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  260. package/src/vite/index.ts +10 -3
  261. package/src/vite/plugin-types.ts +111 -72
  262. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  263. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  264. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  265. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  266. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  267. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  268. package/src/vite/plugins/expose-action-id.ts +55 -33
  269. package/src/vite/plugins/expose-id-utils.ts +24 -8
  270. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  271. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  272. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  273. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  274. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  275. package/src/vite/plugins/performance-tracks.ts +92 -0
  276. package/src/vite/plugins/refresh-cmd.ts +127 -0
  277. package/src/vite/plugins/use-cache-transform.ts +65 -50
  278. package/src/vite/plugins/version-injector.ts +39 -23
  279. package/src/vite/plugins/version-plugin.ts +72 -3
  280. package/src/vite/plugins/virtual-entries.ts +2 -2
  281. package/src/vite/rango.ts +265 -226
  282. package/src/vite/router-discovery.ts +924 -137
  283. package/src/vite/utils/ast-handler-extract.ts +15 -15
  284. package/src/vite/utils/banner.ts +4 -4
  285. package/src/vite/utils/bundle-analysis.ts +4 -2
  286. package/src/vite/utils/client-chunks.ts +190 -0
  287. package/src/vite/utils/forward-user-plugins.ts +193 -0
  288. package/src/vite/utils/manifest-utils.ts +21 -5
  289. package/src/vite/utils/package-resolution.ts +41 -1
  290. package/src/vite/utils/prerender-utils.ts +98 -5
  291. package/src/vite/utils/shared-utils.ts +109 -27
  292. package/src/browser/action-response-classifier.ts +0 -99
  293. package/src/route-definition/route-function.ts +0 -119
@@ -22,10 +22,13 @@ import type {
22
22
  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
+ import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import { setAppVersion } from "./app-version.js";
25
27
  import {
26
28
  isInterceptSegment,
27
29
  splitInterceptSegments,
28
30
  } from "./intercept-utils.js";
31
+ import { createAppShellRef } from "./app-shell.js";
29
32
 
30
33
  // Vite HMR types are provided by vite/client
31
34
 
@@ -112,13 +115,20 @@ export interface BrowserAppContext {
112
115
  warmupEnabled?: boolean;
113
116
  /** App version for prefetch version mismatch detection */
114
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;
115
125
  }
116
126
 
117
127
  // Module-level state for the initialized app
118
128
  let browserAppContext: BrowserAppContext | null = null;
119
129
 
120
130
  /**
121
- * Initialize the browser app. Must be called before rendering RSCRouter.
131
+ * Initialize the browser app. Must be called before rendering Rango.
122
132
  *
123
133
  * This function:
124
134
  * - Loads the initial RSC payload from the stream
@@ -138,7 +148,6 @@ export async function initBrowserApp(
138
148
  initialTheme,
139
149
  } = options;
140
150
 
141
- // Load initial payload from SSR-injected __FLIGHT_DATA__
142
151
  const initialPayload =
143
152
  await deps.createFromReadableStream<RscPayload>(rscStream);
144
153
 
@@ -163,6 +172,12 @@ export async function initBrowserApp(
163
172
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
164
173
  });
165
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
+
166
181
  // Create event controller for reactive state management
167
182
  const eventController = createEventController({
168
183
  initialLocation: new URL(window.location.href),
@@ -197,19 +212,43 @@ export async function initBrowserApp(
197
212
  // Create composable utilities
198
213
  const client = createNavigationClient(deps);
199
214
 
200
- // Extract rootLayout and version from metadata for browser-side re-renders
201
- 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.
202
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
+ });
203
227
 
204
- // Initialize the localStorage state key for browser HTTP cache invalidation.
205
- // Uses the build version so a new deploy automatically busts all cached prefetches.
206
- initRangoState(version ?? "0");
228
+ // Initialize the localStorage state key for cache invalidation.
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);
233
+
234
+ // Initialize the in-memory prefetch cache TTL from server config.
235
+ // A value of 0 disables the cache; undefined falls back to the module default.
236
+ const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
237
+ if (prefetchCacheTTL !== undefined) {
238
+ initPrefetchCache(prefetchCacheTTL);
239
+ }
207
240
 
208
- // 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.
209
244
  const renderSegments = (
210
245
  segments: ResolvedSegment[],
211
246
  options?: RenderSegmentsOptions,
212
- ) => baseRenderSegments(segments, { ...options, rootLayout });
247
+ ) =>
248
+ baseRenderSegments(segments, {
249
+ ...options,
250
+ rootLayout: appShellRef.get().rootLayout,
251
+ });
213
252
 
214
253
  // Lazy reference for navigation bridge — the action bridge is created first
215
254
  // but may need to trigger SPA navigation for action redirects.
@@ -223,7 +262,6 @@ export async function initBrowserApp(
223
262
  deps,
224
263
  onUpdate: (update) => store.emitUpdate(update),
225
264
  renderSegments,
226
- version,
227
265
  onNavigate: (url, options) => {
228
266
  if (!navigateFn) {
229
267
  window.location.href = url;
@@ -241,7 +279,8 @@ export async function initBrowserApp(
241
279
  client,
242
280
  onUpdate: (update) => store.emitUpdate(update),
243
281
  renderSegments,
244
- version,
282
+ version: version,
283
+ appShellRef,
245
284
  });
246
285
 
247
286
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -255,75 +294,157 @@ export async function initBrowserApp(
255
294
  // Build initial tree with rootLayout
256
295
  const initialTree = renderSegments(initialPayload.metadata!.segments);
257
296
 
258
- // 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).
259
301
  if (import.meta.hot) {
260
- import.meta.hot.on("rsc:update", async () => {
261
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
262
-
263
- const handle = eventController.startNavigation(window.location.href, {
264
- replace: true,
265
- });
266
- const streamingToken = handle.startStreaming();
267
-
268
- const interceptSourceUrl = store.getInterceptSourceUrl();
269
-
270
- try {
271
- const { payload, streamComplete } = await client.fetchPartial({
272
- targetUrl: window.location.href,
273
- segmentIds: [],
274
- previousUrl: store.getSegmentState().currentUrl,
275
- interceptSourceUrl: interceptSourceUrl || undefined,
276
- 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,
277
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
+ });
278
354
 
279
- if (payload.metadata?.isPartial) {
280
- const segments = payload.metadata.segments || [];
281
- const matched = payload.metadata.matched || [];
355
+ if (abort.signal.aborted) return;
282
356
 
283
- // Derive intercept state from the returned payload, not the
284
- // pre-fetch store snapshot. If the HMR edit removed intercept
285
- // behavior, the response won't contain intercept segments.
286
- 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
+ }
287
363
 
288
- // Sync store intercept state with what the server returned
289
- if (!responseIsIntercept && interceptSourceUrl) {
290
- 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);
291
384
  }
292
385
 
293
- store.setSegmentIds(matched);
294
- 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
+ }
295
429
 
296
- const historyKey = generateHistoryKey(window.location.href, {
297
- intercept: responseIsIntercept,
298
- });
299
- store.setHistoryKey(historyKey);
300
- const currentHandleData = eventController.getHandleState().data;
301
- store.cacheSegmentsForHistory(
302
- historyKey,
303
- segments,
304
- currentHandleData,
305
- );
306
-
307
- const { main, intercept } = splitInterceptSegments(segments);
308
- store.emitUpdate({
309
- root: renderSegments(main, {
310
- interceptSegments: intercept.length > 0 ? intercept : undefined,
311
- }),
312
- metadata: payload.metadata,
313
- });
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]();
314
442
  }
315
-
316
- await streamComplete;
317
- handle.complete(new URL(window.location.href));
318
- console.log("[RSCRouter] HMR: RSC stream complete");
319
- } finally {
320
- streamingToken.end();
321
- handle[Symbol.dispose]();
322
- }
443
+ }, 200);
323
444
  });
324
445
  }
325
446
 
326
- // Store context for RSCRouter component
447
+ // Store context for Rango component
327
448
  const context: BrowserAppContext = {
328
449
  store,
329
450
  eventController,
@@ -334,6 +455,7 @@ export async function initBrowserApp(
334
455
  initialTheme: effectiveInitialTheme,
335
456
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
336
457
  version,
458
+ appShellRef,
337
459
  };
338
460
  browserAppContext = context;
339
461
 
@@ -346,7 +468,7 @@ export async function initBrowserApp(
346
468
  export function getBrowserAppContext(): BrowserAppContext {
347
469
  if (!browserAppContext) {
348
470
  throw new Error(
349
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
471
+ "Rango: initBrowserApp() must be called before rendering Rango",
350
472
  );
351
473
  }
352
474
  return browserAppContext;
@@ -360,18 +482,18 @@ export function resetBrowserAppContext(): void {
360
482
  }
361
483
 
362
484
  /**
363
- * Props for the RSCRouter component
485
+ * Props for the Rango component
364
486
  */
365
- export interface RSCRouterProps {}
487
+ export interface RangoProps {}
366
488
 
367
489
  /**
368
- * RSCRouter component - renders the RSC router with all internal wiring.
490
+ * Rango component - renders the RSC router with all internal wiring.
369
491
  *
370
492
  * Must be called after initBrowserApp() has completed.
371
493
  *
372
494
  * @example
373
495
  * ```tsx
374
- * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
496
+ * import { initBrowserApp, Rango } from "rsc-router/browser";
375
497
  * import { rscStream } from "rsc-html-stream/client";
376
498
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
377
499
  *
@@ -381,14 +503,14 @@ export interface RSCRouterProps {}
381
503
  * hydrateRoot(
382
504
  * document,
383
505
  * <React.StrictMode>
384
- * <RSCRouter />
506
+ * <Rango />
385
507
  * </React.StrictMode>
386
508
  * );
387
509
  * }
388
510
  * main();
389
511
  * ```
390
512
  */
391
- export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
513
+ export function Rango(_props: RangoProps): React.ReactElement {
392
514
  const {
393
515
  store,
394
516
  eventController,
@@ -399,6 +521,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
399
521
  initialTheme,
400
522
  warmupEnabled,
401
523
  version,
524
+ appShellRef,
402
525
  } = getBrowserAppContext();
403
526
 
404
527
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -418,6 +541,8 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
418
541
  initialTheme={initialTheme}
419
542
  warmupEnabled={warmupEnabled}
420
543
  version={version}
544
+ basename={initialPayload.metadata?.basename}
545
+ appShellRef={appShellRef}
421
546
  />
422
547
  );
423
548
  }
@@ -10,6 +10,15 @@
10
10
 
11
11
  import { debugLog } from "./logging.js";
12
12
 
13
+ /**
14
+ * Defers a callback to the next animation frame.
15
+ * Falls back to setTimeout(0) in environments without requestAnimationFrame.
16
+ */
17
+ const deferToNextPaint: (fn: () => void) => void =
18
+ typeof requestAnimationFrame === "function"
19
+ ? requestAnimationFrame
20
+ : (fn) => setTimeout(fn, 0);
21
+
13
22
  const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
14
23
 
15
24
  /**
@@ -264,51 +273,35 @@ export function restoreScrollPosition(options?: {
264
273
  return false;
265
274
  }
266
275
 
267
- // Check if page is tall enough to scroll to saved position
268
- const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
269
- const canScrollToPosition = savedY <= maxScrollY;
270
-
271
- if (canScrollToPosition) {
272
- window.scrollTo(0, savedY);
273
- debugLog("[Scroll] Restored position:", savedY, "for key:", key);
274
- return true;
275
- }
276
-
277
- // Scroll as far as we can for now
278
- window.scrollTo(0, maxScrollY);
279
- debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
280
-
281
- // Poll while streaming until we can scroll to target position
276
+ // If streaming, poll until streaming ends then scroll to saved position
282
277
  if (options?.retryIfStreaming && options?.isStreaming?.()) {
283
278
  const startTime = Date.now();
284
279
 
285
280
  pendingPollInterval = setInterval(() => {
286
- // Stop if we've exceeded the timeout
287
281
  if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
288
282
  debugLog("[Scroll] Polling timeout, giving up");
289
283
  cancelScrollRestorationPolling();
290
284
  return;
291
285
  }
292
286
 
293
- // Stop if streaming ended
294
287
  if (!options.isStreaming?.()) {
295
- debugLog("[Scroll] Streaming ended, stopping poll");
296
- cancelScrollRestorationPolling();
297
- return;
298
- }
299
-
300
- // Check if we can now scroll to the target position
301
- const currentMaxScrollY =
302
- document.documentElement.scrollHeight - window.innerHeight;
303
- if (savedY <= currentMaxScrollY) {
304
288
  window.scrollTo(0, savedY);
305
- debugLog("[Scroll] Poll restored position:", savedY);
289
+ debugLog("[Scroll] Restored after streaming:", savedY);
306
290
  cancelScrollRestorationPolling();
307
291
  }
308
292
  }, SCROLL_POLL_INTERVAL_MS);
293
+
294
+ return true;
309
295
  }
310
296
 
311
- return false;
297
+ // Not streaming — scroll after React commits and browser paints.
298
+ // startTransition defers the DOM commit, so scrolling synchronously
299
+ // would be overwritten when React replaces the content.
300
+ deferToNextPaint(() => {
301
+ window.scrollTo(0, savedY);
302
+ debugLog("[Scroll] Restored position:", savedY, "for key:", key);
303
+ });
304
+ return true;
312
305
  }
313
306
 
314
307
  /**
@@ -339,6 +332,8 @@ export function scrollToHash(): boolean {
339
332
  * Scroll to top of page
340
333
  */
341
334
  export function scrollToTop(): void {
335
+ if (typeof window === "undefined") return;
336
+ if (typeof window.scrollTo !== "function") return;
342
337
  window.scrollTo(0, 0);
343
338
  }
344
339
 
@@ -363,31 +358,43 @@ export function handleNavigationEnd(options: {
363
358
  scroll?: boolean;
364
359
  isStreaming?: () => boolean;
365
360
  }): void {
366
- if (!initialized) {
367
- return;
368
- }
369
-
370
361
  const { restore = false, scroll = true, isStreaming } = options;
371
362
 
372
- // Don't scroll if explicitly disabled
373
- if (scroll === false) {
363
+ // Don't scroll if explicitly disabled or not in a browser
364
+ if (scroll === false || typeof window === "undefined") {
374
365
  return;
375
366
  }
376
367
 
377
- // For back/forward (restore), try to restore saved position
378
- if (restore) {
368
+ // Save/restore requires initialization (sessionStorage, history state).
369
+ // But basic scroll-to-top and hash scrolling work without it — this
370
+ // matters during cross-app navigation where ScrollRestoration unmounts
371
+ // and remounts, creating a brief window where initialized is false.
372
+ if (restore && initialized) {
379
373
  if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
380
374
  return;
381
375
  }
382
376
  // Fall through to hash or top if no saved position
383
377
  }
384
378
 
385
- // Try hash scrolling first
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.)
386
395
  if (scrollToHash()) {
387
396
  return;
388
397
  }
389
-
390
- // Default: scroll to top
391
398
  scrollToTop();
392
399
  }
393
400
 
@@ -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,15 +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
- // - Clear truthy loading (active skeleton) to prevent suspense on cached content
164
- if (actor !== "action") {
165
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
166
- return { ...fromCache, loading: undefined };
167
- }
168
- }
169
-
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.
170
197
  return fromCache;
171
198
  })
172
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"}. ` +