@rangojs/router 0.0.0-experimental.d20dd405 → 0.0.0-experimental.dacec167

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 (212) hide show
  1. package/README.md +8 -8
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +914 -485
  5. package/package.json +55 -11
  6. package/skills/bundle-analysis/SKILL.md +159 -0
  7. package/skills/cache-guide/SKILL.md +220 -30
  8. package/skills/caching/SKILL.md +116 -8
  9. package/skills/composability/SKILL.md +27 -2
  10. package/skills/document-cache/SKILL.md +78 -55
  11. package/skills/handler-use/SKILL.md +1 -1
  12. package/skills/hooks/SKILL.md +196 -21
  13. package/skills/host-router/SKILL.md +45 -20
  14. package/skills/intercept/SKILL.md +1 -4
  15. package/skills/layout/SKILL.md +4 -7
  16. package/skills/links/SKILL.md +22 -10
  17. package/skills/loader/SKILL.md +149 -6
  18. package/skills/middleware/SKILL.md +13 -9
  19. package/skills/migrate-nextjs/SKILL.md +1 -1
  20. package/skills/mime-routes/SKILL.md +27 -0
  21. package/skills/observability/SKILL.md +137 -0
  22. package/skills/parallel/SKILL.md +3 -6
  23. package/skills/prerender/SKILL.md +14 -33
  24. package/skills/rango/SKILL.md +242 -26
  25. package/skills/react-compiler/SKILL.md +168 -0
  26. package/skills/response-routes/SKILL.md +58 -9
  27. package/skills/route/SKILL.md +9 -4
  28. package/skills/router-setup/SKILL.md +3 -3
  29. package/skills/server-actions/SKILL.md +53 -41
  30. package/skills/testing/SKILL.md +778 -0
  31. package/skills/typesafety/SKILL.md +310 -26
  32. package/skills/use-cache/SKILL.md +34 -5
  33. package/skills/view-transitions/SKILL.md +85 -3
  34. package/src/__augment-tests__/augment.ts +81 -0
  35. package/src/__augment-tests__/augmented.check.ts +117 -0
  36. package/src/browser/action-coordinator.ts +53 -36
  37. package/src/browser/event-controller.ts +42 -66
  38. package/src/browser/history-state.ts +21 -0
  39. package/src/browser/index.ts +3 -3
  40. package/src/browser/navigation-bridge.ts +9 -67
  41. package/src/browser/navigation-client.ts +12 -15
  42. package/src/browser/navigation-store.ts +7 -8
  43. package/src/browser/navigation-transaction.ts +10 -28
  44. package/src/browser/partial-update.ts +8 -16
  45. package/src/browser/react/NavigationProvider.tsx +55 -65
  46. package/src/browser/react/location-state-shared.ts +175 -4
  47. package/src/browser/react/location-state.ts +39 -13
  48. package/src/browser/react/use-handle.ts +17 -9
  49. package/src/browser/react/use-params.ts +3 -4
  50. package/src/browser/react/use-reverse.ts +19 -12
  51. package/src/browser/react/use-router.ts +14 -1
  52. package/src/browser/response-adapter.ts +25 -0
  53. package/src/browser/rsc-router.tsx +30 -16
  54. package/src/browser/scroll-restoration.ts +30 -25
  55. package/src/browser/segment-structure-assert.ts +2 -2
  56. package/src/browser/server-action-bridge.ts +23 -30
  57. package/src/browser/types.ts +2 -0
  58. package/src/build/collect-fallback-refs.ts +107 -0
  59. package/src/build/generate-manifest.ts +60 -35
  60. package/src/build/generate-route-types.ts +2 -0
  61. package/src/build/index.ts +2 -0
  62. package/src/build/route-types/codegen.ts +4 -4
  63. package/src/build/route-types/include-resolution.ts +1 -1
  64. package/src/build/route-types/per-module-writer.ts +7 -4
  65. package/src/build/route-types/router-processing.ts +55 -14
  66. package/src/build/route-types/scan-filter.ts +1 -1
  67. package/src/build/route-types/source-scan.ts +118 -0
  68. package/src/build/runtime-discovery.ts +9 -20
  69. package/src/cache/cache-scope.ts +28 -42
  70. package/src/cache/cf/cf-cache-store.ts +49 -6
  71. package/src/client.tsx +5 -7
  72. package/src/context-var.ts +5 -5
  73. package/src/decode-loader-results.ts +36 -0
  74. package/src/errors.ts +30 -1
  75. package/src/handle.ts +26 -13
  76. package/src/host/index.ts +2 -2
  77. package/src/host/router.ts +129 -57
  78. package/src/host/types.ts +31 -2
  79. package/src/host/utils.ts +1 -1
  80. package/src/href-client.ts +136 -19
  81. package/src/index.rsc.ts +6 -4
  82. package/src/index.ts +13 -6
  83. package/src/loader-store.ts +500 -0
  84. package/src/loader.rsc.ts +21 -6
  85. package/src/loader.ts +3 -10
  86. package/src/missing-id-error.ts +68 -0
  87. package/src/prerender.ts +4 -4
  88. package/src/response-utils.ts +9 -0
  89. package/src/reverse.ts +16 -13
  90. package/src/route-content-wrapper.tsx +6 -28
  91. package/src/route-definition/dsl-helpers.ts +238 -263
  92. package/src/route-definition/helper-factories.ts +29 -139
  93. package/src/route-definition/helpers-types.ts +37 -14
  94. package/src/route-definition/use-item-types.ts +32 -0
  95. package/src/route-types.ts +19 -41
  96. package/src/router/basename.ts +14 -0
  97. package/src/router/content-negotiation.ts +15 -2
  98. package/src/router/error-handling.ts +1 -1
  99. package/src/router/intercept-resolution.ts +4 -18
  100. package/src/router/lazy-includes.ts +2 -2
  101. package/src/router/loader-resolution.ts +16 -2
  102. package/src/router/match-handlers.ts +62 -20
  103. package/src/router/match-middleware/cache-lookup.ts +44 -91
  104. package/src/router/match-middleware/cache-store.ts +3 -2
  105. package/src/router/match-result.ts +32 -30
  106. package/src/router/metrics.ts +1 -1
  107. package/src/router/middleware-types.ts +1 -1
  108. package/src/router/middleware.ts +46 -78
  109. package/src/router/prerender-match.ts +1 -1
  110. package/src/router/preview-match.ts +3 -1
  111. package/src/router/request-classification.ts +4 -28
  112. package/src/router/revalidation.ts +43 -1
  113. package/src/router/router-interfaces.ts +45 -28
  114. package/src/router/router-options.ts +40 -1
  115. package/src/router/router-registry.ts +2 -5
  116. package/src/router/segment-resolution/fresh.ts +19 -6
  117. package/src/router/segment-resolution/revalidation.ts +19 -6
  118. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  119. package/src/router/telemetry.ts +99 -0
  120. package/src/router/types.ts +8 -0
  121. package/src/router.ts +37 -21
  122. package/src/rsc/handler-context.ts +2 -2
  123. package/src/rsc/handler.ts +20 -65
  124. package/src/rsc/helpers.ts +22 -2
  125. package/src/rsc/index.ts +1 -1
  126. package/src/rsc/origin-guard.ts +28 -10
  127. package/src/rsc/response-route-handler.ts +32 -52
  128. package/src/rsc/rsc-rendering.ts +27 -53
  129. package/src/rsc/runtime-warnings.ts +9 -10
  130. package/src/rsc/server-action.ts +13 -37
  131. package/src/rsc/ssr-setup.ts +16 -0
  132. package/src/rsc/types.ts +2 -2
  133. package/src/search-params.ts +4 -4
  134. package/src/segment-system.tsx +64 -49
  135. package/src/serialize.ts +243 -0
  136. package/src/server/context.ts +118 -51
  137. package/src/server/cookie-store.ts +28 -4
  138. package/src/server/request-context.ts +10 -0
  139. package/src/static-handler.ts +1 -1
  140. package/src/testing/cache-status.ts +166 -0
  141. package/src/testing/collect-handle.ts +63 -0
  142. package/src/testing/dispatch.ts +440 -0
  143. package/src/testing/dom.entry.ts +22 -0
  144. package/src/testing/e2e/fixture.ts +154 -0
  145. package/src/testing/e2e/index.ts +149 -0
  146. package/src/testing/e2e/matchers.ts +51 -0
  147. package/src/testing/e2e/page-helpers.ts +272 -0
  148. package/src/testing/e2e/parity.ts +306 -0
  149. package/src/testing/e2e/server.ts +183 -0
  150. package/src/testing/flight-matchers.ts +104 -0
  151. package/src/testing/flight-runtime.d.ts +57 -0
  152. package/src/testing/flight-tree.ts +320 -0
  153. package/src/testing/flight.entry.ts +39 -0
  154. package/src/testing/flight.ts +197 -0
  155. package/src/testing/generated-routes.ts +223 -0
  156. package/src/testing/index.ts +106 -0
  157. package/src/testing/internal/context.ts +331 -0
  158. package/src/testing/internal/flight-client-globals.ts +30 -0
  159. package/src/testing/render-route.tsx +565 -0
  160. package/src/testing/run-loader.ts +341 -0
  161. package/src/testing/run-middleware.ts +188 -0
  162. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  163. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  164. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  165. package/src/testing/vitest-stubs/version.ts +5 -0
  166. package/src/testing/vitest.ts +270 -0
  167. package/src/types/global-namespace.ts +39 -26
  168. package/src/types/handler-context.ts +56 -11
  169. package/src/types/index.ts +1 -0
  170. package/src/types/segments.ts +18 -1
  171. package/src/urls/include-helper.ts +10 -53
  172. package/src/urls/index.ts +0 -3
  173. package/src/urls/path-helper-types.ts +11 -3
  174. package/src/urls/path-helper.ts +17 -52
  175. package/src/urls/pattern-types.ts +36 -19
  176. package/src/urls/response-types.ts +20 -19
  177. package/src/urls/type-extraction.ts +26 -116
  178. package/src/urls/urls-function.ts +1 -5
  179. package/src/use-loader.tsx +413 -42
  180. package/src/vite/debug.ts +1 -0
  181. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  182. package/src/vite/discovery/discover-routers.ts +70 -48
  183. package/src/vite/discovery/discovery-errors.ts +194 -0
  184. package/src/vite/discovery/prerender-collection.ts +19 -25
  185. package/src/vite/discovery/route-types-writer.ts +40 -84
  186. package/src/vite/discovery/state.ts +33 -0
  187. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  188. package/src/vite/index.ts +2 -0
  189. package/src/vite/plugin-types.ts +67 -0
  190. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  191. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  192. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -1
  193. package/src/vite/plugins/expose-action-id.ts +2 -2
  194. package/src/vite/plugins/expose-id-utils.ts +12 -8
  195. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  196. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  197. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  198. package/src/vite/plugins/expose-internal-ids.ts +47 -67
  199. package/src/vite/plugins/performance-tracks.ts +12 -16
  200. package/src/vite/plugins/use-cache-transform.ts +13 -11
  201. package/src/vite/plugins/version-injector.ts +2 -12
  202. package/src/vite/plugins/version-plugin.ts +59 -2
  203. package/src/vite/plugins/virtual-entries.ts +2 -2
  204. package/src/vite/rango.ts +67 -15
  205. package/src/vite/router-discovery.ts +208 -63
  206. package/src/vite/utils/ast-handler-extract.ts +15 -15
  207. package/src/vite/utils/bundle-analysis.ts +4 -2
  208. package/src/vite/utils/client-chunks.ts +190 -0
  209. package/src/vite/utils/forward-user-plugins.ts +193 -0
  210. package/src/vite/utils/manifest-utils.ts +21 -5
  211. package/src/vite/utils/shared-utils.ts +107 -26
  212. package/src/browser/action-response-classifier.ts +0 -99
@@ -32,27 +32,35 @@ import { shallowEqual } from "./shallow-equal.js";
32
32
  * const lastCrumb = useHandle(Breadcrumbs, (data) => data.at(-1));
33
33
  * ```
34
34
  */
35
- export function useHandle<T, A>(handle: Handle<T, A>): A;
35
+ export function useHandle<T, A>(handle: Handle<T, A>): Rango.FlightSerialize<A>;
36
36
  export function useHandle<T, A, S>(
37
37
  handle: Handle<T, A>,
38
- selector: (data: A) => S,
38
+ selector: (data: Rango.FlightSerialize<A>) => S,
39
39
  ): S;
40
40
  export function useHandle<T, A, S>(
41
41
  handle: Handle<T, A>,
42
- selector?: (data: A) => S,
43
- ): A | S {
42
+ selector?: (data: Rango.FlightSerialize<A>) => S,
43
+ ): Rango.FlightSerialize<A> | S {
44
44
  const ctx = useContext(NavigationStoreContext);
45
45
 
46
46
  // Initial state from context event controller, or empty fallback without provider.
47
- const [value, setValue] = useState<A | S>(() => {
47
+ const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
48
48
  if (!ctx) {
49
- const collected = collectHandleData(handle, {}, []);
49
+ const collected = collectHandleData(
50
+ handle,
51
+ {},
52
+ [],
53
+ ) as Rango.FlightSerialize<A>;
50
54
  return selector ? selector(collected) : collected;
51
55
  }
52
56
 
53
57
  // On client, use event controller state
54
58
  const state = ctx.eventController.getHandleState();
55
- const collected = collectHandleData(handle, state.data, state.segmentOrder);
59
+ const collected = collectHandleData(
60
+ handle,
61
+ state.data,
62
+ state.segmentOrder,
63
+ ) as Rango.FlightSerialize<A>;
56
64
  return selector ? selector(collected) : collected;
57
65
  });
58
66
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
@@ -76,7 +84,7 @@ export function useHandle<T, A, S>(
76
84
  handle,
77
85
  currentHandleState.data,
78
86
  currentHandleState.segmentOrder,
79
- );
87
+ ) as Rango.FlightSerialize<A>;
80
88
  const currentValue = selectorRef.current
81
89
  ? selectorRef.current(currentCollected)
82
90
  : currentCollected;
@@ -93,7 +101,7 @@ export function useHandle<T, A, S>(
93
101
  handle,
94
102
  state.data,
95
103
  state.segmentOrder,
96
- );
104
+ ) as Rango.FlightSerialize<A>;
97
105
  const nextValue = selectorRef.current
98
106
  ? selectorRef.current(collected)
99
107
  : collected;
@@ -4,6 +4,8 @@ import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
5
  import { shallowEqual } from "./shallow-equal.js";
6
6
 
7
+ const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
8
+
7
9
  /**
8
10
  * Hook to access the current route params.
9
11
  *
@@ -43,10 +45,7 @@ export function useParams<T>(
43
45
  const ctx = useContext(NavigationStoreContext);
44
46
 
45
47
  const [value, setValue] = useState<T | Record<string, string>>(() => {
46
- if (!ctx) {
47
- return selector ? selector({}) : {};
48
- }
49
- const params = ctx.eventController.getParams();
48
+ const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
50
49
  return selector ? selector(params) : params;
51
50
  });
52
51
 
@@ -34,11 +34,18 @@ function joinMount(mount: string, pattern: string): string {
34
34
  /**
35
35
  * Mount-aware reverse function for a locally-imported `routes` map.
36
36
  *
37
- * Resolves dot-prefixed route names against the passed `routes` (typically
38
- * a generated `routes` from a `urls()` module's `.gen.ts`), prefixes the
39
- * result with the surrounding `include()` mount path, and substitutes
40
- * params — auto-filling from the current matched route's params and
41
- * letting explicit params override.
37
+ * The `routes` map you pass IS the scope: `reverse("name")` looks the name up
38
+ * in that map (verbatim), prefixes the result with the surrounding `include()`
39
+ * mount path via `useMount()`, and substitutes params — auto-filling from the
40
+ * current matched route's params, with explicit params overriding. A module's
41
+ * components can therefore reverse their own routes without knowing where the
42
+ * module is mounted: include it under any prefix and the URLs resolve correctly.
43
+ *
44
+ * The leading dot is optional and cosmetic: `reverse("post")` and
45
+ * `reverse(".post")` resolve identically. The dot exists only as a readability
46
+ * convention and for parity with `ctx.reverse(".name")` on the server; here the
47
+ * passed map is the scope, so there is no separate global namespace to
48
+ * disambiguate and the dot carries no meaning.
42
49
  *
43
50
  * @example
44
51
  * ```tsx
@@ -50,8 +57,8 @@ function joinMount(mount: string, pattern: string): string {
50
57
  * const reverse = useReverse(blogRoutes);
51
58
  * return (
52
59
  * <>
53
- * <Link to={reverse(".index")}>Blog</Link>
54
- * <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
60
+ * <Link to={reverse("index")}>Blog</Link>
61
+ * <Link to={reverse("post", { postId: "hello" })}>Post</Link>
55
62
  * </>
56
63
  * );
57
64
  * }
@@ -69,14 +76,14 @@ export function useReverse<const TRoutes extends LocalRouteMap>(
69
76
  explicitParams?: Record<string, string | undefined>,
70
77
  search?: Record<string, unknown>,
71
78
  ): string => {
72
- if (!name.startsWith(".")) {
73
- throw new Error(`Local route names must start with ".": "${name}"`);
74
- }
75
- const lookupName = name.slice(1);
79
+ // The leading dot is optional. The passed map IS the scope, so a dot to
80
+ // signal "local" is unnecessary "detail" and ".detail" resolve the same.
81
+ // A dot is accepted (and stripped) for readability / ctx.reverse parity.
82
+ const lookupName = name.startsWith(".") ? name.slice(1) : name;
76
83
  const entry = (routes as LocalRouteMap)[lookupName];
77
84
  const pattern = getPattern(entry);
78
85
  if (pattern === undefined) {
79
- throw new Error(`Unknown local route: "${name}"`);
86
+ throw new Error(`Unknown route: "${name}"`);
80
87
  }
81
88
 
82
89
  const joined = joinMount(mount, pattern);
@@ -72,7 +72,20 @@ export function useRouter(): RouterInstance {
72
72
  },
73
73
 
74
74
  back(): void {
75
- 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
+ }
76
89
  },
77
90
 
78
91
  forward(): void {
@@ -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
@@ -128,7 +128,7 @@ export interface BrowserAppContext {
128
128
  let browserAppContext: BrowserAppContext | null = null;
129
129
 
130
130
  /**
131
- * Initialize the browser app. Must be called before rendering RSCRouter.
131
+ * Initialize the browser app. Must be called before rendering Rango.
132
132
  *
133
133
  * This function:
134
134
  * - Loads the initial RSC payload from the stream
@@ -325,11 +325,11 @@ export async function initBrowserApp(
325
325
  // full lifecycle (fetching + streaming, before commit) without
326
326
  // blocking on server actions.
327
327
  if (eventController.getState().isNavigating) {
328
- console.log("[RSCRouter] HMR: Skipping — navigation in progress");
328
+ console.log("[Rango] HMR: Skipping — navigation in progress");
329
329
  return;
330
330
  }
331
331
 
332
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
332
+ console.log("[Rango] HMR: Server update, refetching RSC");
333
333
 
334
334
  const abort = new AbortController();
335
335
  hmrAbort = abort;
@@ -364,11 +364,18 @@ export async function initBrowserApp(
364
364
  // Update version BEFORE rebuilding state so that
365
365
  // clearHistoryCache() runs first, then the fresh segment
366
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.
367
373
  const newVersion = payload.metadata.version;
368
- if (newVersion && newVersion !== version) {
374
+ const currentVersion = navigationBridge.getVersion();
375
+ if (newVersion && newVersion !== currentVersion) {
369
376
  console.log(
370
- "[RSCRouter] HMR: version changed",
371
- version,
377
+ "[Rango] HMR: version changed",
378
+ currentVersion,
372
379
  "→",
373
380
  newVersion,
374
381
  "clearing caches",
@@ -376,6 +383,13 @@ export async function initBrowserApp(
376
383
  navigationBridge.updateVersion(newVersion);
377
384
  }
378
385
 
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.
379
393
  if (payload.metadata?.isPartial) {
380
394
  const segments = payload.metadata.segments || [];
381
395
  const matched = payload.metadata.matched || [];
@@ -415,10 +429,10 @@ export async function initBrowserApp(
415
429
 
416
430
  await streamComplete;
417
431
  handle.complete(new URL(window.location.href));
418
- console.log("[RSCRouter] HMR: RSC stream complete");
432
+ console.log("[Rango] HMR: RSC stream complete");
419
433
  } catch (err) {
420
434
  if (abort.signal.aborted) return;
421
- console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
435
+ console.warn("[Rango] HMR: Refetch failed, reloading page", err);
422
436
  window.location.reload();
423
437
  return;
424
438
  } finally {
@@ -430,7 +444,7 @@ export async function initBrowserApp(
430
444
  });
431
445
  }
432
446
 
433
- // Store context for RSCRouter component
447
+ // Store context for Rango component
434
448
  const context: BrowserAppContext = {
435
449
  store,
436
450
  eventController,
@@ -454,7 +468,7 @@ export async function initBrowserApp(
454
468
  export function getBrowserAppContext(): BrowserAppContext {
455
469
  if (!browserAppContext) {
456
470
  throw new Error(
457
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
471
+ "Rango: initBrowserApp() must be called before rendering Rango",
458
472
  );
459
473
  }
460
474
  return browserAppContext;
@@ -468,18 +482,18 @@ export function resetBrowserAppContext(): void {
468
482
  }
469
483
 
470
484
  /**
471
- * Props for the RSCRouter component
485
+ * Props for the Rango component
472
486
  */
473
- export interface RSCRouterProps {}
487
+ export interface RangoProps {}
474
488
 
475
489
  /**
476
- * RSCRouter component - renders the RSC router with all internal wiring.
490
+ * Rango component - renders the RSC router with all internal wiring.
477
491
  *
478
492
  * Must be called after initBrowserApp() has completed.
479
493
  *
480
494
  * @example
481
495
  * ```tsx
482
- * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
496
+ * import { initBrowserApp, Rango } from "rsc-router/browser";
483
497
  * import { rscStream } from "rsc-html-stream/client";
484
498
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
485
499
  *
@@ -489,14 +503,14 @@ export interface RSCRouterProps {}
489
503
  * hydrateRoot(
490
504
  * document,
491
505
  * <React.StrictMode>
492
- * <RSCRouter />
506
+ * <Rango />
493
507
  * </React.StrictMode>
494
508
  * );
495
509
  * }
496
510
  * main();
497
511
  * ```
498
512
  */
499
- export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
513
+ export function Rango(_props: RangoProps): React.ReactElement {
500
514
  const {
501
515
  store,
502
516
  eventController,
@@ -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
  /**
@@ -285,28 +294,13 @@ export function restoreScrollPosition(options?: {
285
294
  return true;
286
295
  }
287
296
 
288
- // Defer the actual scrollTo to the next animation frame so the new
289
- // tree's layout has been measured. Synchronous scrollTo from inside
290
- // useLayoutEffect runs against an as-yet-unmeasured DOM and silently
291
- // clamps savedY to the old (or zero) maxScroll — a tall destination
292
- // restored from a short source then lands at Y=0 instead of the
293
- // saved position.
294
- //
295
- // (scrollToTop / scrollToHash on forward navigations stay synchronous
296
- // because Y=0 / a hash element are robust against unmeasured layout —
297
- // they don't depend on the new tree's full scrollHeight, and sync
298
- // scrolling lands before startViewTransition's snapshot capture.)
299
- if (typeof requestAnimationFrame === "function") {
300
- requestAnimationFrame(() => {
301
- if (typeof window.scrollTo === "function") {
302
- window.scrollTo(0, savedY);
303
- debugLog("[Scroll] Restored position:", savedY, "for key:", key);
304
- }
305
- });
306
- } else if (typeof window.scrollTo === "function") {
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(() => {
307
301
  window.scrollTo(0, savedY);
308
302
  debugLog("[Scroll] Restored position:", savedY, "for key:", key);
309
- }
303
+ });
310
304
  return true;
311
305
  }
312
306
 
@@ -382,11 +376,22 @@ export function handleNavigationEnd(options: {
382
376
  // Fall through to hash or top if no saved position
383
377
  }
384
378
 
385
- // Sync hash scroll / scroll-to-top see the long comment in
386
- // restoreScrollPosition above. handleNavigationEnd runs from
387
- // NavigationProvider's useLayoutEffect (post-commit, pre-paint) and from
388
- // bridge sites that don't trigger a React commit, so a synchronous
389
- // scrollTo is captured by the next paint / startViewTransition snapshot.
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.)
390
395
  if (scrollToHash()) {
391
396
  return;
392
397
  }
@@ -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
 
@@ -552,6 +552,8 @@ export interface NavigationBridge {
552
552
  refresh(): Promise<void>;
553
553
  handlePopstate(): Promise<void>;
554
554
  registerLinkInterception(): () => void;
555
+ /** Current RSC version (live, reflects the latest updateVersion). */
556
+ getVersion(): string | undefined;
555
557
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
556
558
  updateVersion(newVersion: string): void;
557
559
  /**
@@ -0,0 +1,107 @@
1
+ // Collect the `"use client"` client-reference keys reachable from an error /
2
+ // notFound boundary registration, for routing them into the dedicated
3
+ // `app-fallback` chunk (see vite/utils/client-chunks.ts).
4
+ //
5
+ // A boundary registration is not always a bare client element. The common,
6
+ // load-bearing pattern wraps the client boundary in providers a thrown handler
7
+ // needs (the layout that would normally supply them did not mount):
8
+ //
9
+ // defaultErrorBoundary: ({ error }) => (
10
+ // <FallbackIntl locales={...}>
11
+ // <ThemedError error={error} /> // <- the real "use client" boundary
12
+ // </FallbackIntl>
13
+ // )
14
+ //
15
+ // So the value may be (a) a handler FUNCTION returning a tree, or (b) an element
16
+ // tree with the client boundary nested below server wrappers. We:
17
+ // 1. If it's a function, CALL it with synthetic props to get the returned tree.
18
+ // This only constructs JSX — the inner components are element `type`s, never
19
+ // invoked — so no hooks run. Guarded: a boundary that needs a real render
20
+ // context (request globals, etc.) throws and is skipped (graceful: it simply
21
+ // stays on the default grouping, as before).
22
+ // 2. Walk the resulting tree and report every element whose `.type` is a
23
+ // plugin-rsc client reference.
24
+ //
25
+ // Limit: a boundary that *conditionally* renders different client components based
26
+ // on the runtime error cannot be resolved statically — only the branch taken with
27
+ // the synthetic error is seen. Such cases fall back to the default chunk; the
28
+ // custom `clientChunks` function is the escape hatch.
29
+
30
+ const CLIENT_REF = Symbol.for("react.client.reference");
31
+ const MAX_DEPTH = 40;
32
+
33
+ // Synthetic props covering the error-boundary (`{ error, reset }`) and notFound
34
+ // (`{ pathname }`) handler shapes. The handler destructures what it needs.
35
+ const SYNTHETIC_PROPS = {
36
+ error: new Error("rango: build-time fallback-chunk discovery"),
37
+ reset: () => {},
38
+ pathname: "/",
39
+ info: { componentStack: "" },
40
+ };
41
+
42
+ interface MaybeElement {
43
+ type?: { $$typeof?: symbol; $$id?: string };
44
+ props?: Record<string, unknown>;
45
+ }
46
+
47
+ function isReactNodeLike(v: unknown): boolean {
48
+ return (
49
+ Array.isArray(v) ||
50
+ (typeof v === "object" && v !== null && "$$typeof" in (v as object))
51
+ );
52
+ }
53
+
54
+ function walkElementTree(
55
+ node: unknown,
56
+ report: (refKey: string) => void,
57
+ depth: number,
58
+ ): void {
59
+ if (node == null || depth > MAX_DEPTH) return;
60
+ if (Array.isArray(node)) {
61
+ for (const child of node) walkElementTree(child, report, depth + 1);
62
+ return;
63
+ }
64
+ if (typeof node !== "object") return;
65
+
66
+ const el = node as MaybeElement;
67
+ const type = el.type;
68
+ if (type?.$$typeof === CLIENT_REF && typeof type.$$id === "string") {
69
+ // $$id is `<referenceKey>#<exportName>` in build mode — keep the referenceKey.
70
+ report(type.$$id.split("#")[0]);
71
+ }
72
+
73
+ const props = el.props;
74
+ if (props && typeof props === "object") {
75
+ // Children are always nodes; other props are followed only when they look
76
+ // like React nodes (slots/icons), never arbitrary data objects.
77
+ walkElementTree(props.children, report, depth + 1);
78
+ for (const key in props) {
79
+ if (key === "children") continue;
80
+ const value = props[key];
81
+ if (isReactNodeLike(value)) walkElementTree(value, report, depth + 1);
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Report every `"use client"` client-reference key reachable from a single
88
+ * error/notFound boundary registration (handler function or element tree).
89
+ */
90
+ export function collectFallbackClientRefs(
91
+ boundary: unknown,
92
+ report: (refKey: string) => void,
93
+ ): void {
94
+ try {
95
+ let node = boundary;
96
+ if (typeof node === "function") {
97
+ node = (node as (props: unknown) => unknown)(SYNTHETIC_PROPS);
98
+ }
99
+ walkElementTree(node, report, 0);
100
+ } catch {
101
+ // The boundary needs a real render context (request globals, hooks at the
102
+ // top level) or its tree has hostile getters. Its client refs can't be
103
+ // resolved statically — skip. It stays on the default grouping (no
104
+ // regression vs. not collecting), and the custom clientChunks fn is the
105
+ // escape hatch for such cases.
106
+ }
107
+ }