@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945

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 (239) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2103 -861
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +66 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +26 -4
  19. package/skills/layout/SKILL.md +6 -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 +12 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +238 -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 +33 -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/tailwind/SKILL.md +27 -3
  37. package/skills/typesafety/SKILL.md +319 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +116 -0
  42. package/src/browser/action-coordinator.ts +53 -36
  43. package/src/browser/app-shell.ts +39 -0
  44. package/src/browser/event-controller.ts +86 -70
  45. package/src/browser/history-state.ts +21 -0
  46. package/src/browser/index.ts +3 -3
  47. package/src/browser/navigation-bridge.ts +29 -9
  48. package/src/browser/navigation-client.ts +99 -77
  49. package/src/browser/navigation-store.ts +7 -8
  50. package/src/browser/navigation-transaction.ts +10 -28
  51. package/src/browser/partial-update.ts +60 -40
  52. package/src/browser/prefetch/cache.ts +196 -49
  53. package/src/browser/prefetch/fetch.ts +203 -59
  54. package/src/browser/prefetch/queue.ts +36 -5
  55. package/src/browser/rango-state.ts +37 -13
  56. package/src/browser/react/Link.tsx +18 -13
  57. package/src/browser/react/NavigationProvider.tsx +75 -31
  58. package/src/browser/react/filter-segment-order.ts +51 -7
  59. package/src/browser/react/index.ts +3 -0
  60. package/src/browser/react/location-state-shared.ts +175 -4
  61. package/src/browser/react/location-state.ts +39 -13
  62. package/src/browser/react/use-handle.ts +17 -9
  63. package/src/browser/react/use-navigation.ts +22 -2
  64. package/src/browser/react/use-params.ts +20 -8
  65. package/src/browser/react/use-reverse.ts +106 -0
  66. package/src/browser/react/use-router.ts +23 -2
  67. package/src/browser/react/use-segments.ts +11 -8
  68. package/src/browser/response-adapter.ts +52 -1
  69. package/src/browser/rsc-router.tsx +71 -22
  70. package/src/browser/scroll-restoration.ts +22 -14
  71. package/src/browser/segment-reconciler.ts +10 -14
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +44 -30
  74. package/src/browser/types.ts +12 -2
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +45 -1
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-runtime.ts +17 -5
  89. package/src/cache/cache-scope.ts +51 -49
  90. package/src/cache/cf/cf-cache-store.ts +502 -32
  91. package/src/cache/cf/index.ts +3 -0
  92. package/src/cache/handle-snapshot.ts +103 -0
  93. package/src/cache/index.ts +3 -0
  94. package/src/cache/memory-segment-store.ts +3 -2
  95. package/src/cache/types.ts +10 -6
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +96 -205
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -4
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -21
  107. package/src/index.rsc.ts +10 -6
  108. package/src/index.ts +17 -8
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender/store.ts +9 -7
  115. package/src/prerender.ts +4 -4
  116. package/src/response-utils.ts +37 -0
  117. package/src/reverse.ts +65 -39
  118. package/src/route-content-wrapper.tsx +6 -28
  119. package/src/route-definition/dsl-helpers.ts +253 -265
  120. package/src/route-definition/helper-factories.ts +29 -139
  121. package/src/route-definition/helpers-types.ts +43 -15
  122. package/src/route-definition/resolve-handler-use.ts +6 -0
  123. package/src/route-definition/use-item-types.ts +32 -0
  124. package/src/route-types.ts +26 -41
  125. package/src/router/content-negotiation.ts +15 -2
  126. package/src/router/error-handling.ts +1 -1
  127. package/src/router/find-match.ts +54 -6
  128. package/src/router/handler-context.ts +21 -41
  129. package/src/router/intercept-resolution.ts +4 -18
  130. package/src/router/lazy-includes.ts +41 -22
  131. package/src/router/loader-resolution.ts +82 -36
  132. package/src/router/manifest.ts +41 -19
  133. package/src/router/match-api.ts +4 -3
  134. package/src/router/match-handlers.ts +1 -0
  135. package/src/router/match-middleware/cache-lookup.ts +57 -95
  136. package/src/router/match-middleware/cache-store.ts +3 -2
  137. package/src/router/match-result.ts +53 -32
  138. package/src/router/metrics.ts +1 -1
  139. package/src/router/middleware-types.ts +15 -26
  140. package/src/router/middleware.ts +99 -84
  141. package/src/router/pattern-matching.ts +116 -19
  142. package/src/router/prerender-match.ts +40 -15
  143. package/src/router/preview-match.ts +3 -1
  144. package/src/router/request-classification.ts +40 -37
  145. package/src/router/revalidation.ts +58 -2
  146. package/src/router/router-interfaces.ts +51 -35
  147. package/src/router/router-options.ts +25 -1
  148. package/src/router/router-registry.ts +2 -5
  149. package/src/router/segment-resolution/fresh.ts +27 -6
  150. package/src/router/segment-resolution/revalidation.ts +147 -106
  151. package/src/router/segment-resolution/static-store.ts +19 -5
  152. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  153. package/src/router/substitute-pattern-params.ts +56 -0
  154. package/src/router/trie-matching.ts +40 -16
  155. package/src/router/types.ts +8 -0
  156. package/src/router/url-params.ts +49 -0
  157. package/src/router.ts +37 -25
  158. package/src/rsc/handler-context.ts +2 -2
  159. package/src/rsc/handler.ts +58 -77
  160. package/src/rsc/helpers.ts +72 -43
  161. package/src/rsc/index.ts +1 -1
  162. package/src/rsc/manifest-init.ts +28 -41
  163. package/src/rsc/origin-guard.ts +30 -10
  164. package/src/rsc/progressive-enhancement.ts +4 -0
  165. package/src/rsc/response-error.ts +79 -12
  166. package/src/rsc/response-route-handler.ts +76 -61
  167. package/src/rsc/rsc-rendering.ts +45 -51
  168. package/src/rsc/runtime-warnings.ts +9 -10
  169. package/src/rsc/server-action.ts +33 -39
  170. package/src/rsc/ssr-setup.ts +16 -0
  171. package/src/rsc/types.ts +8 -2
  172. package/src/search-params.ts +4 -4
  173. package/src/segment-content-promise.ts +67 -0
  174. package/src/segment-loader-promise.ts +122 -0
  175. package/src/segment-system.tsx +132 -116
  176. package/src/serialize.ts +243 -0
  177. package/src/server/context.ts +175 -53
  178. package/src/server/cookie-store.ts +28 -4
  179. package/src/server/request-context.ts +57 -51
  180. package/src/ssr/index.tsx +5 -1
  181. package/src/static-handler.ts +1 -1
  182. package/src/types/global-namespace.ts +39 -26
  183. package/src/types/handler-context.ts +68 -50
  184. package/src/types/index.ts +1 -0
  185. package/src/types/loader-types.ts +11 -9
  186. package/src/types/request-scope.ts +126 -0
  187. package/src/types/route-entry.ts +11 -0
  188. package/src/types/segments.ts +35 -2
  189. package/src/urls/include-helper.ts +34 -67
  190. package/src/urls/index.ts +1 -5
  191. package/src/urls/path-helper-types.ts +17 -3
  192. package/src/urls/path-helper.ts +17 -52
  193. package/src/urls/pattern-types.ts +36 -19
  194. package/src/urls/response-types.ts +22 -29
  195. package/src/urls/type-extraction.ts +58 -139
  196. package/src/urls/urls-function.ts +1 -5
  197. package/src/use-loader.tsx +413 -42
  198. package/src/vite/debug.ts +185 -0
  199. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  200. package/src/vite/discovery/discover-routers.ts +106 -75
  201. package/src/vite/discovery/discovery-errors.ts +194 -0
  202. package/src/vite/discovery/gate-state.ts +171 -0
  203. package/src/vite/discovery/prerender-collection.ts +72 -31
  204. package/src/vite/discovery/route-types-writer.ts +40 -84
  205. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  206. package/src/vite/discovery/state.ts +33 -0
  207. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  208. package/src/vite/index.ts +2 -0
  209. package/src/vite/plugin-types.ts +67 -0
  210. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  211. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  212. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  213. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  214. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  215. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  216. package/src/vite/plugins/expose-action-id.ts +54 -30
  217. package/src/vite/plugins/expose-id-utils.ts +12 -8
  218. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  219. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  220. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  221. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  222. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  223. package/src/vite/plugins/performance-tracks.ts +29 -25
  224. package/src/vite/plugins/use-cache-transform.ts +65 -50
  225. package/src/vite/plugins/version-injector.ts +39 -23
  226. package/src/vite/plugins/version-plugin.ts +59 -2
  227. package/src/vite/plugins/virtual-entries.ts +2 -2
  228. package/src/vite/rango.ts +116 -29
  229. package/src/vite/router-discovery.ts +753 -104
  230. package/src/vite/utils/ast-handler-extract.ts +15 -15
  231. package/src/vite/utils/banner.ts +1 -1
  232. package/src/vite/utils/bundle-analysis.ts +4 -2
  233. package/src/vite/utils/client-chunks.ts +190 -0
  234. package/src/vite/utils/forward-user-plugins.ts +193 -0
  235. package/src/vite/utils/manifest-utils.ts +8 -59
  236. package/src/vite/utils/package-resolution.ts +41 -1
  237. package/src/vite/utils/prerender-utils.ts +5 -4
  238. package/src/vite/utils/shared-utils.ts +107 -26
  239. package/src/browser/action-response-classifier.ts +0 -99
@@ -53,6 +53,12 @@ export function useNavigation<T>(
53
53
  });
54
54
  const prevState = useRef(baseValue);
55
55
 
56
+ // Tracks whether the most recent setOptimisticValue call pinned the value
57
+ // to a non-idle state. Used to decide whether to emit a release update when
58
+ // returning to idle, so the optimistic store doesn't stay pinned if a
59
+ // parent transition (e.g. <Link> click) is still pending.
60
+ const optimisticPinnedRef = useRef(false);
61
+
56
62
  // useOptimistic allows immediate updates during transitions/actions
57
63
  const [value, setOptimisticValue] = useOptimistic(baseValue);
58
64
 
@@ -82,11 +88,25 @@ export function useNavigation<T>(
82
88
  const hasInflightActions =
83
89
  ctx.eventController.getInflightActions().size > 0;
84
90
 
85
- if (hasInflightActions || publicState.state !== "idle") {
86
- // Use optimistic update for immediate feedback during transitions
91
+ const shouldPin = hasInflightActions || publicState.state !== "idle";
92
+
93
+ if (shouldPin) {
94
+ // Pin the optimistic store so the loading value shows immediately
95
+ // even if a parent transition (e.g. <Link> click) defers the
96
+ // urgent setBaseValue commit.
97
+ startTransition(() => {
98
+ setOptimisticValue(nextSelected);
99
+ });
100
+ optimisticPinnedRef.current = true;
101
+ } else if (optimisticPinnedRef.current) {
102
+ // Release a previously-pinned optimistic value. Without this,
103
+ // useOptimistic keeps returning the stale loading value while
104
+ // any parent transition is still pending, even after baseValue
105
+ // flipped to idle.
87
106
  startTransition(() => {
88
107
  setOptimisticValue(nextSelected);
89
108
  });
109
+ optimisticPinnedRef.current = false;
90
110
  }
91
111
 
92
112
  // Always update base state so UI reflects current state
@@ -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
  *
@@ -16,24 +18,34 @@ import { shallowEqual } from "./shallow-equal.js";
16
18
  * const params = useParams();
17
19
  * // { productId: "123" }
18
20
  *
21
+ * // Annotate the expected shape via a generic
22
+ * const { productId } = useParams<{ productId: string }>();
23
+ *
19
24
  * // With selector
20
25
  * const productId = useParams(p => p.productId);
21
26
  * ```
22
27
  */
23
- export function useParams(): Record<string, string>;
28
+ // `T extends object` (not `Record<string, string | undefined>`) so that
29
+ // interface shapes pass the constraint — interfaces lack an implicit
30
+ // index signature and would otherwise be rejected. The generic is a
31
+ // shape annotation, not a runtime check; the body always returns the
32
+ // underlying params map unchanged. The default and selector input use
33
+ // `string | undefined` because absent optional params are omitted from
34
+ // the params record at runtime — the type must reflect that so callers
35
+ // don't write `p.locale.length` and crash when the segment is absent.
36
+ export function useParams<
37
+ T extends object = Record<string, string | undefined>,
38
+ >(): Readonly<T>;
24
39
  export function useParams<T>(
25
- selector: (params: Record<string, string>) => T,
40
+ selector: (params: Record<string, string | undefined>) => T,
26
41
  ): T;
27
42
  export function useParams<T>(
28
- selector?: (params: Record<string, string>) => T,
29
- ): T | Record<string, string> {
43
+ selector?: (params: Record<string, string | undefined>) => T,
44
+ ): T | Record<string, string | undefined> {
30
45
  const ctx = useContext(NavigationStoreContext);
31
46
 
32
47
  const [value, setValue] = useState<T | Record<string, string>>(() => {
33
- if (!ctx) {
34
- return selector ? selector({}) : {};
35
- }
36
- const params = ctx.eventController.getParams();
48
+ const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
37
49
  return selector ? selector(params) : params;
38
50
  });
39
51
 
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import type { LocalReverseFunction } from "../../reverse.js";
5
+ import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
6
+ import { serializeSearchParams } from "../../search-params.js";
7
+ import { useMount } from "./use-mount.js";
8
+ import { useParams } from "./use-params.js";
9
+
10
+ type RouteEntry = string | { readonly path: string };
11
+ type LocalRouteMap = Readonly<Record<string, RouteEntry>>;
12
+
13
+ function getPattern(entry: RouteEntry | undefined): string | undefined {
14
+ if (entry === undefined) return undefined;
15
+ return typeof entry === "string" ? entry : entry.path;
16
+ }
17
+
18
+ /**
19
+ * Join an include mount prefix with a mount-relative pattern.
20
+ *
21
+ * `pattern === "/"` is the index of the local module — under a non-root
22
+ * mount it must collapse so `/` under `/blog` becomes `/blog`, not
23
+ * `/blog/`. This matches `ctx.reverse(".index")` on the server.
24
+ */
25
+ function joinMount(mount: string, pattern: string): string {
26
+ if (pattern === "/") {
27
+ if (mount === "" || mount === "/") return "/";
28
+ return mount.endsWith("/") ? mount.slice(0, -1) : mount;
29
+ }
30
+ const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
31
+ return normalizedMount + pattern;
32
+ }
33
+
34
+ /**
35
+ * Mount-aware reverse function for a locally-imported `routes` map.
36
+ *
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.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * "use client";
53
+ * import { Link, useReverse } from "@rangojs/router/client";
54
+ * import { routes as blogRoutes } from "../urls/blog.gen.js";
55
+ *
56
+ * function BlogNav() {
57
+ * const reverse = useReverse(blogRoutes);
58
+ * return (
59
+ * <>
60
+ * <Link to={reverse("index")}>Blog</Link>
61
+ * <Link to={reverse("post", { postId: "hello" })}>Post</Link>
62
+ * </>
63
+ * );
64
+ * }
65
+ * ```
66
+ */
67
+ export function useReverse<const TRoutes extends LocalRouteMap>(
68
+ routes: TRoutes,
69
+ ): LocalReverseFunction<TRoutes> {
70
+ const mount = useMount();
71
+ const currentParams = useParams();
72
+
73
+ return useCallback(
74
+ ((
75
+ name: string,
76
+ explicitParams?: Record<string, string | undefined>,
77
+ search?: Record<string, unknown>,
78
+ ): string => {
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;
83
+ const entry = (routes as LocalRouteMap)[lookupName];
84
+ const pattern = getPattern(entry);
85
+ if (pattern === undefined) {
86
+ throw new Error(`Unknown route: "${name}"`);
87
+ }
88
+
89
+ const joined = joinMount(mount, pattern);
90
+
91
+ const mergedParams = explicitParams
92
+ ? { ...currentParams, ...explicitParams }
93
+ : currentParams;
94
+
95
+ const substituted = substitutePatternParams(joined, mergedParams, name);
96
+
97
+ if (search) {
98
+ const qs = serializeSearchParams(search);
99
+ if (qs) return `${substituted}?${qs}`;
100
+ }
101
+
102
+ return substituted;
103
+ }) as LocalReverseFunction<TRoutes>,
104
+ [routes, mount, currentParams],
105
+ );
106
+ }
@@ -13,6 +13,11 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
13
13
  * useRouter() do not re-render on navigation state changes.
14
14
  * For reactive navigation state, use useNavigation() instead.
15
15
  *
16
+ * Methods read `basename` from the context on each call. It is set once from
17
+ * the initial payload and is stable within a session — a cross-app navigation
18
+ * is a full document load (X-RSC-Reload), so the target app mounts fresh with
19
+ * its own basename.
20
+ *
16
21
  * @example
17
22
  * ```tsx
18
23
  * const router = useRouter();
@@ -29,7 +34,10 @@ export function useRouter(): RouterInstance {
29
34
  throw new Error("useRouter must be used within NavigationProvider");
30
35
  }
31
36
 
32
- // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
37
+ // Stable reference: ctx itself is stable, and reads on each method call
38
+ // pick up live basename values from the context (backed by a live ref
39
+ // in NavigationProvider), so app-switch transitions are reflected without
40
+ // recreating this object.
33
41
  return useMemo<RouterInstance>(() => {
34
42
  /** Prefix a root-relative path with basename if not already prefixed. */
35
43
  function withBasename(url: string): string {
@@ -65,7 +73,20 @@ export function useRouter(): RouterInstance {
65
73
  },
66
74
 
67
75
  back(): void {
68
- window.history.back();
76
+ // Avoid escaping the host on the first entry of this session.
77
+ // Prefer the Navigation API; fall back to the router-stamped
78
+ // history.state.idx (set by pushHistoryWithIdx) for older browsers.
79
+ const nav = (window as { navigation?: { canGoBack: boolean } })
80
+ .navigation;
81
+ const canGoBack =
82
+ nav && typeof nav.canGoBack === "boolean"
83
+ ? nav.canGoBack
84
+ : ((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0;
85
+ if (canGoBack) {
86
+ window.history.back();
87
+ } else {
88
+ ctx.navigate(withBasename("/"), { replace: true });
89
+ }
69
90
  },
70
91
 
71
92
  forward(): void {
@@ -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,51 @@ export function emptyResponse(): Response {
24
24
  return new Response(null, { status: 200 });
25
25
  }
26
26
 
27
+ /**
28
+ * Whether an RSC content response carries a server-stamped router identity
29
+ * (`X-RSC-Router-Id`) that DIFFERS from the id this client expects (its own
30
+ * routerId, also sent as `_rsc_rid`). Pre-decode integrity check: lets a caller
31
+ * refuse a foreign app's payload before `createFromFetch` imports its chunks.
32
+ *
33
+ * True ONLY when both the header and the expected id are present and differ. An
34
+ * absent header (control-only reload/redirect responses are not stamped) or an
35
+ * absent expected id (e.g. before the client is seeded) is a pass-through —
36
+ * never a false reject.
37
+ */
38
+ export function isForeignRouterId(
39
+ response: Response,
40
+ expectedId: string | undefined,
41
+ ): boolean {
42
+ const got = response.headers.get("X-RSC-Router-Id");
43
+ if (!got || !expectedId) return false;
44
+ return got !== expectedId;
45
+ }
46
+
47
+ /**
48
+ * Handle the X-RSC-Reload control header (server requests a full page reload on
49
+ * a version mismatch). Returns a short-circuit response when the header is
50
+ * present -- emptyResponse() if the URL was blocked by origin validation, or a
51
+ * never-resolving promise while the page reloads -- and null when absent, so
52
+ * the caller continues processing (e.g. the X-RSC-Redirect check). Scoped to
53
+ * X-RSC-Reload only; redirect handling differs between callers.
54
+ */
55
+ export function handleReloadHeader(
56
+ response: Response,
57
+ opts: { onBlocked: () => void; onReload: (url: string) => void },
58
+ ): Response | Promise<Response> | null {
59
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
60
+ if (reload === "blocked") {
61
+ opts.onBlocked();
62
+ return emptyResponse();
63
+ }
64
+ if (reload) {
65
+ opts.onReload(reload.url);
66
+ window.location.href = reload.url;
67
+ return new Promise<Response>(() => {});
68
+ }
69
+ return null;
70
+ }
71
+
27
72
  /**
28
73
  * Tee a response body for RSC parsing and stream completion tracking.
29
74
  * Returns a new Response with one branch; the other is consumed to detect
@@ -31,11 +76,17 @@ export function emptyResponse(): Response {
31
76
  *
32
77
  * If the response has no body, onComplete fires synchronously.
33
78
  * If signal is provided, an abort cancels the tracking reader.
79
+ *
80
+ * `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
81
+ * low-priority prefetch that is aborted or never consumed can error its stream
82
+ * benignly, which is not worth surfacing. The fresh-navigation path keeps the
83
+ * log (default), where a stream error reflects a real failed navigation.
34
84
  */
35
85
  export function teeWithCompletion(
36
86
  response: Response,
37
87
  onComplete: () => void,
38
88
  signal?: AbortSignal,
89
+ silent = false,
39
90
  ): Response {
40
91
  if (!response.body) {
41
92
  onComplete();
@@ -59,7 +110,7 @@ export function teeWithCompletion(
59
110
  onComplete();
60
111
  }
61
112
  })().catch((error) => {
62
- if (!signal?.aborted) {
113
+ if (!silent && !signal?.aborted) {
63
114
  console.error("[Browser] Error reading tracking stream:", error);
64
115
  }
65
116
  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,22 @@ export interface BrowserAppContext {
114
116
  warmupEnabled?: boolean;
115
117
  /** App version for prefetch version mismatch detection */
116
118
  version?: string;
119
+ /**
120
+ * App-shell ref, read through on each render so renderSegments and the
121
+ * NavigationProvider see rootLayout/basename/version without closing over a
122
+ * stale snapshot. Set once from the initial payload and not swapped within a
123
+ * session: a cross-app navigation is a full document load (X-RSC-Reload), so
124
+ * the target app establishes its own shell on load. Theme, warmup, and
125
+ * prefetch TTL are document-lifetime too (see AppShell).
126
+ */
127
+ appShellRef?: import("./app-shell.js").AppShellRef;
117
128
  }
118
129
 
119
130
  // Module-level state for the initialized app
120
131
  let browserAppContext: BrowserAppContext | null = null;
121
132
 
122
133
  /**
123
- * Initialize the browser app. Must be called before rendering RSCRouter.
134
+ * Initialize the browser app. Must be called before rendering Rango.
124
135
  *
125
136
  * This function:
126
137
  * - Loads the initial RSC payload from the stream
@@ -204,13 +215,23 @@ export async function initBrowserApp(
204
215
  // Create composable utilities
205
216
  const client = createNavigationClient(deps);
206
217
 
207
- // Extract rootLayout and version from metadata for browser-side re-renders
208
- const rootLayout = initialPayload.metadata?.rootLayout;
218
+ // Capture the per-router app-shell. rootLayout, basename, and version live
219
+ // here and are read through the ref at call time rather than closed over.
220
+ // It is set once from the initial payload and not swapped within a session:
221
+ // a cross-app navigation is a full document load (X-RSC-Reload), so the
222
+ // target app establishes its own shell on load.
209
223
  const version = initialPayload.metadata?.version;
224
+ const appShellRef = createAppShellRef({
225
+ routerId: initialPayload.metadata?.routerId,
226
+ rootLayout: initialPayload.metadata?.rootLayout,
227
+ basename: initialPayload.metadata?.basename,
228
+ version,
229
+ });
210
230
 
211
231
  // 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");
232
+ // The build version busts cached prefetches on deploy; the routerId
233
+ // namespaces the key so sibling apps on the same origin don't collide.
234
+ initRangoState(version ?? "0", initialPayload.metadata?.routerId);
214
235
  setAppVersion(version);
215
236
 
216
237
  // Initialize the in-memory prefetch cache TTL from server config.
@@ -220,11 +241,22 @@ export async function initBrowserApp(
220
241
  initPrefetchCache(prefetchCacheTTL);
221
242
  }
222
243
 
223
- // Create a bound renderSegments that includes rootLayout
244
+ // Wire the RSC decoder so prefetches decode eagerly and warm the route's
245
+ // client chunks (same createFromFetch the navigation client uses).
246
+ setPrefetchDecoder((response) => deps.createFromFetch<RscPayload>(response));
247
+
248
+ // Create a bound renderSegments that reads rootLayout through the shell ref.
249
+ // The shell is set once at init and not swapped within a session (a cross-app
250
+ // navigation is a full document load), so this always renders this app's
251
+ // Document; reading through the ref just avoids closing over a stale value.
224
252
  const renderSegments = (
225
253
  segments: ResolvedSegment[],
226
254
  options?: RenderSegmentsOptions,
227
- ) => baseRenderSegments(segments, { ...options, rootLayout });
255
+ ) =>
256
+ baseRenderSegments(segments, {
257
+ ...options,
258
+ rootLayout: appShellRef.get().rootLayout,
259
+ });
228
260
 
229
261
  // Lazy reference for navigation bridge — the action bridge is created first
230
262
  // but may need to trigger SPA navigation for action redirects.
@@ -300,11 +332,11 @@ export async function initBrowserApp(
300
332
  // full lifecycle (fetching + streaming, before commit) without
301
333
  // blocking on server actions.
302
334
  if (eventController.getState().isNavigating) {
303
- console.log("[RSCRouter] HMR: Skipping — navigation in progress");
335
+ console.log("[Rango] HMR: Skipping — navigation in progress");
304
336
  return;
305
337
  }
306
338
 
307
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
339
+ console.log("[Rango] HMR: Server update, refetching RSC");
308
340
 
309
341
  const abort = new AbortController();
310
342
  hmrAbort = abort;
@@ -339,11 +371,18 @@ export async function initBrowserApp(
339
371
  // Update version BEFORE rebuilding state so that
340
372
  // clearHistoryCache() runs first, then the fresh segment
341
373
  // cache entry we create below survives.
374
+ //
375
+ // Compare against the bridge's live version, not the init-time
376
+ // `version` const: after the first HMR bump the const is stale, so a
377
+ // later update with an unchanged version would otherwise re-clear the
378
+ // cache and re-broadcast across tabs/apps. The live read fires only
379
+ // on a genuine version change.
342
380
  const newVersion = payload.metadata.version;
343
- if (newVersion && newVersion !== version) {
381
+ const currentVersion = navigationBridge.getVersion();
382
+ if (newVersion && newVersion !== currentVersion) {
344
383
  console.log(
345
- "[RSCRouter] HMR: version changed",
346
- version,
384
+ "[Rango] HMR: version changed",
385
+ currentVersion,
347
386
  "→",
348
387
  newVersion,
349
388
  "clearing caches",
@@ -351,6 +390,13 @@ export async function initBrowserApp(
351
390
  navigationBridge.updateVersion(newVersion);
352
391
  }
353
392
 
393
+ // Apply only partial segment updates. A non-partial payload during
394
+ // HMR is transient: the worker route table is still rebuilding after
395
+ // the edit, so the URL momentarily resolves to not-found/catch-all.
396
+ // Skip it -- the debounced follow-up refetch returns the settled
397
+ // route's partial payload and renders it below. We never reload here:
398
+ // a paramless document GET would run the SSR path and surface the
399
+ // not-found page during that same transient.
354
400
  if (payload.metadata?.isPartial) {
355
401
  const segments = payload.metadata.segments || [];
356
402
  const matched = payload.metadata.matched || [];
@@ -390,10 +436,10 @@ export async function initBrowserApp(
390
436
 
391
437
  await streamComplete;
392
438
  handle.complete(new URL(window.location.href));
393
- console.log("[RSCRouter] HMR: RSC stream complete");
439
+ console.log("[Rango] HMR: RSC stream complete");
394
440
  } catch (err) {
395
441
  if (abort.signal.aborted) return;
396
- console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
442
+ console.warn("[Rango] HMR: Refetch failed, reloading page", err);
397
443
  window.location.reload();
398
444
  return;
399
445
  } finally {
@@ -405,7 +451,7 @@ export async function initBrowserApp(
405
451
  });
406
452
  }
407
453
 
408
- // Store context for RSCRouter component
454
+ // Store context for Rango component
409
455
  const context: BrowserAppContext = {
410
456
  store,
411
457
  eventController,
@@ -416,6 +462,7 @@ export async function initBrowserApp(
416
462
  initialTheme: effectiveInitialTheme,
417
463
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
418
464
  version,
465
+ appShellRef,
419
466
  };
420
467
  browserAppContext = context;
421
468
 
@@ -428,7 +475,7 @@ export async function initBrowserApp(
428
475
  export function getBrowserAppContext(): BrowserAppContext {
429
476
  if (!browserAppContext) {
430
477
  throw new Error(
431
- "RSCRouter: initBrowserApp() must be called before rendering RSCRouter",
478
+ "Rango: initBrowserApp() must be called before rendering Rango",
432
479
  );
433
480
  }
434
481
  return browserAppContext;
@@ -442,18 +489,18 @@ export function resetBrowserAppContext(): void {
442
489
  }
443
490
 
444
491
  /**
445
- * Props for the RSCRouter component
492
+ * Props for the Rango component
446
493
  */
447
- export interface RSCRouterProps {}
494
+ export interface RangoProps {}
448
495
 
449
496
  /**
450
- * RSCRouter component - renders the RSC router with all internal wiring.
497
+ * Rango component - renders the RSC router with all internal wiring.
451
498
  *
452
499
  * Must be called after initBrowserApp() has completed.
453
500
  *
454
501
  * @example
455
502
  * ```tsx
456
- * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
503
+ * import { initBrowserApp, Rango } from "rsc-router/browser";
457
504
  * import { rscStream } from "rsc-html-stream/client";
458
505
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
459
506
  *
@@ -463,14 +510,14 @@ export interface RSCRouterProps {}
463
510
  * hydrateRoot(
464
511
  * document,
465
512
  * <React.StrictMode>
466
- * <RSCRouter />
513
+ * <Rango />
467
514
  * </React.StrictMode>
468
515
  * );
469
516
  * }
470
517
  * main();
471
518
  * ```
472
519
  */
473
- export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
520
+ export function Rango(_props: RangoProps): React.ReactElement {
474
521
  const {
475
522
  store,
476
523
  eventController,
@@ -481,6 +528,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
481
528
  initialTheme,
482
529
  warmupEnabled,
483
530
  version,
531
+ appShellRef,
484
532
  } = getBrowserAppContext();
485
533
 
486
534
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -501,6 +549,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
501
549
  warmupEnabled={warmupEnabled}
502
550
  version={version}
503
551
  basename={initialPayload.metadata?.basename}
552
+ appShellRef={appShellRef}
504
553
  />
505
554
  );
506
555
  }
@@ -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
  /**