@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -12,26 +12,7 @@ import type { Handle } from "../../handle.js";
12
12
  import { getCollectFn } from "../../handle.js";
13
13
  import type { HandleData } from "../types.js";
14
14
  import { NavigationStoreContext } from "./context.js";
15
-
16
- /**
17
- * SSR module-level state.
18
- * Populated by initHandleDataSync before React renders.
19
- * Used by useState initializer during SSR.
20
- */
21
- let ssrHandleData: HandleData = {};
22
- let ssrSegmentOrder: string[] = [];
23
-
24
- /**
25
- * Filter segment IDs to only include routes and layouts.
26
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
27
- */
28
- function filterSegmentOrder(matched: string[]): string[] {
29
- return matched.filter((id) => {
30
- if (id.includes(".@")) return false;
31
- if (/D\d+\./.test(id)) return false;
32
- return true;
33
- });
34
- }
15
+ import { shallowEqual } from "./shallow-equal.js";
35
16
 
36
17
  /**
37
18
  * Resolve the collect function for a handle.
@@ -86,45 +67,6 @@ function collectHandle<T, A>(
86
67
  return collect(segmentArrays);
87
68
  }
88
69
 
89
- /**
90
- * Shallow equality check for selector results.
91
- */
92
- function shallowEqual<T>(a: T, b: T): boolean {
93
- if (Object.is(a, b)) return true;
94
- if (
95
- typeof a !== "object" ||
96
- a === null ||
97
- typeof b !== "object" ||
98
- b === null
99
- ) {
100
- return false;
101
- }
102
- const keysA = Object.keys(a);
103
- const keysB = Object.keys(b);
104
- if (keysA.length !== keysB.length) return false;
105
- for (const key of keysA) {
106
- if (
107
- !Object.hasOwn(b, key) ||
108
- !Object.is((a as any)[key], (b as any)[key])
109
- ) {
110
- return false;
111
- }
112
- }
113
- return true;
114
- }
115
-
116
- /**
117
- * Initialize handle data synchronously for SSR.
118
- * Called before rendering to populate state for useState initializer.
119
- *
120
- * @param data - Handle data from RSC payload
121
- * @param matched - Segment order for reduction
122
- */
123
- export function initHandleDataSync(data: HandleData, matched?: string[]): void {
124
- ssrHandleData = data;
125
- ssrSegmentOrder = filterSegmentOrder(matched ?? []);
126
- }
127
-
128
70
  /**
129
71
  * Hook to access collected handle data.
130
72
  *
@@ -154,11 +96,10 @@ export function useHandle<T, A, S>(
154
96
  ): A | S {
155
97
  const ctx = useContext(NavigationStoreContext);
156
98
 
157
- // Initial state from SSR module state or event controller
99
+ // Initial state from context event controller, or empty fallback without provider.
158
100
  const [value, setValue] = useState<A | S>(() => {
159
- // During SSR, use module-level state
160
- if (typeof document === "undefined" || !ctx) {
161
- const collected = collectHandle(handle, ssrHandleData, ssrSegmentOrder);
101
+ if (!ctx) {
102
+ const collected = collectHandle(handle, {}, []);
162
103
  return selector ? selector(collected) : collected;
163
104
  }
164
105
 
@@ -173,7 +114,7 @@ export function useHandle<T, A, S>(
173
114
  const prevValueRef = useRef(value);
174
115
  prevValueRef.current = value;
175
116
 
176
- // Memoize selector ref
117
+ // Ref keeps the latest selector without re-subscribing on every render.
177
118
  const selectorRef = useRef(selector);
178
119
  selectorRef.current = selector;
179
120
 
@@ -181,6 +122,22 @@ export function useHandle<T, A, S>(
181
122
  useEffect(() => {
182
123
  if (!ctx) return;
183
124
 
125
+ // Sync current state for the (possibly new) handle so that switching
126
+ // handles on an idle page doesn't leave stale data from the old handle.
127
+ const currentHandleState = ctx.eventController.getHandleState();
128
+ const currentCollected = collectHandle(
129
+ handle,
130
+ currentHandleState.data,
131
+ currentHandleState.segmentOrder,
132
+ );
133
+ const currentValue = selectorRef.current
134
+ ? selectorRef.current(currentCollected)
135
+ : currentCollected;
136
+ if (!shallowEqual(currentValue, prevValueRef.current)) {
137
+ prevValueRef.current = currentValue;
138
+ setValue(currentValue);
139
+ }
140
+
184
141
  return ctx.eventController.subscribeToHandles(() => {
185
142
  const state = ctx.eventController.getHandleState();
186
143
  const isAction =
@@ -9,36 +9,10 @@ import {
9
9
  useRef,
10
10
  } from "react";
11
11
  import { NavigationStoreContext } from "./context.js";
12
+ import { shallowEqual } from "./shallow-equal.js";
12
13
  import type { PublicNavigationState } from "../types.js";
13
14
  import type { DerivedNavigationState } from "../event-controller.js";
14
15
 
15
- /**
16
- * Shallow equality check for selector results
17
- */
18
- function shallowEqual<T>(a: T, b: T): boolean {
19
- if (Object.is(a, b)) return true;
20
- if (
21
- typeof a !== "object" ||
22
- a === null ||
23
- typeof b !== "object" ||
24
- b === null
25
- ) {
26
- return false;
27
- }
28
- const keysA = Object.keys(a);
29
- const keysB = Object.keys(b);
30
- if (keysA.length !== keysB.length) return false;
31
- for (const key of keysA) {
32
- if (
33
- !Object.hasOwn(b, key) ||
34
- !Object.is((a as any)[key], (b as any)[key])
35
- ) {
36
- return false;
37
- }
38
- }
39
- return true;
40
- }
41
-
42
16
  /**
43
17
  * Convert derived state to public version (strips inflightActions)
44
18
  */
@@ -69,9 +43,7 @@ export function useNavigation<T>(
69
43
  const ctx = useContext(NavigationStoreContext);
70
44
 
71
45
  if (!ctx) {
72
- throw new Error(
73
- "useNavigation must be used within NavigationStoreContext.Provider",
74
- );
46
+ throw new Error("useNavigation must be used within NavigationProvider");
75
47
  }
76
48
 
77
49
  // Base state for useOptimistic
@@ -84,8 +56,11 @@ export function useNavigation<T>(
84
56
  // useOptimistic allows immediate updates during transitions/actions
85
57
  const [value, setOptimisticValue] = useOptimistic(baseValue);
86
58
 
87
- // Store selector in a ref to avoid re-subscribing when an inline
88
- // function is passed (its identity changes every render).
59
+ // Store selector in a ref so the subscription callback always uses the
60
+ // latest selector without re-subscribing on every render (inline functions
61
+ // have a new identity each render). This is event-driven by design: the
62
+ // value updates when the store emits, not when the selector changes.
63
+ // Between events there is nothing new to select from.
89
64
  const selectorRef = useRef(selector);
90
65
  selectorRef.current = selector;
91
66
 
@@ -2,37 +2,7 @@
2
2
 
3
3
  import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
- import { getSsrParams } from "./use-segments.js";
6
-
7
- /**
8
- * Shallow equality check for selector results
9
- */
10
- function shallowEqual<T>(a: T, b: T): boolean {
11
- if (Object.is(a, b)) return true;
12
- if (
13
- typeof a !== "object" ||
14
- a === null ||
15
- typeof b !== "object" ||
16
- b === null
17
- ) {
18
- return false;
19
- }
20
- const keysA = Object.keys(a);
21
- const keysB = Object.keys(b);
22
- if (keysA.length !== keysB.length) return false;
23
- for (const key of keysA) {
24
- if (
25
- !Object.hasOwn(b, key) ||
26
- !Object.is(
27
- (a as Record<string, unknown>)[key],
28
- (b as Record<string, unknown>)[key],
29
- )
30
- ) {
31
- return false;
32
- }
33
- }
34
- return true;
35
- }
5
+ import { shallowEqual } from "./shallow-equal.js";
36
6
 
37
7
  /**
38
8
  * Hook to access the current route params.
@@ -60,15 +30,16 @@ export function useParams<T>(
60
30
  const ctx = useContext(NavigationStoreContext);
61
31
 
62
32
  const [value, setValue] = useState<T | Record<string, string>>(() => {
63
- if (typeof document === "undefined" || !ctx) {
64
- const ssrParams = getSsrParams();
65
- return selector ? selector(ssrParams) : ssrParams;
33
+ if (!ctx) {
34
+ return selector ? selector({}) : {};
66
35
  }
67
36
  const params = ctx.eventController.getParams();
68
37
  return selector ? selector(params) : params;
69
38
  });
70
39
 
71
40
  const prevValue = useRef(value);
41
+ // Ref keeps the latest selector without re-subscribing. Event-driven by
42
+ // design: value updates on store events, not on selector identity change.
72
43
  const selectorRef = useRef(selector);
73
44
  selectorRef.current = selector;
74
45
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
- import { getSsrPathname } from "./use-segments.js";
6
5
 
7
6
  /**
8
7
  * Hook to access the current pathname.
@@ -20,8 +19,8 @@ export function usePathname(): string {
20
19
  const ctx = useContext(NavigationStoreContext);
21
20
 
22
21
  const [pathname, setPathname] = useState<string>(() => {
23
- if (typeof document === "undefined" || !ctx) {
24
- return getSsrPathname();
22
+ if (!ctx) {
23
+ return "/";
25
24
  }
26
25
  return (ctx.eventController.getState().location as URL).pathname;
27
26
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useContext, useMemo } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
- import { prefetchUrl } from "./prefetch.js";
5
+ import { prefetchDirect } from "../prefetch/fetch.js";
6
6
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
7
7
 
8
8
  /**
@@ -25,9 +25,7 @@ export function useRouter(): RouterInstance {
25
25
  const ctx = useContext(NavigationStoreContext);
26
26
 
27
27
  if (!ctx) {
28
- throw new Error(
29
- "useRouter must be used within NavigationStoreContext.Provider",
30
- );
28
+ throw new Error("useRouter must be used within NavigationProvider");
31
29
  }
32
30
 
33
31
  // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
@@ -46,10 +44,9 @@ export function useRouter(): RouterInstance {
46
44
  },
47
45
 
48
46
  prefetch(url: string): void {
49
- // Guard for SSR where store is null
50
47
  const segmentState = ctx.store?.getSegmentState();
51
48
  if (segmentState) {
52
- prefetchUrl(url, segmentState.currentSegmentIds);
49
+ prefetchDirect(url, segmentState.currentSegmentIds, ctx.version);
53
50
  }
54
51
  },
55
52
 
@@ -41,7 +41,8 @@ export function useSearchParams(): ReadonlyURLSearchParams {
41
41
  const nextSearch = location.searchParams.toString();
42
42
  if (nextSearch !== prevSearch.current) {
43
43
  prevSearch.current = nextSearch;
44
- setSearchParams(location.searchParams);
44
+ // Create a snapshot so callers cannot mutate the source URLSearchParams
45
+ setSearchParams(new URLSearchParams(nextSearch));
45
46
  }
46
47
  };
47
48
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
+ import { shallowEqual } from "./shallow-equal.js";
5
6
 
6
7
  /**
7
8
  * Segments state returned by useSegments hook
@@ -15,86 +16,6 @@ export interface SegmentsState {
15
16
  location: URL;
16
17
  }
17
18
 
18
- /**
19
- * SSR module-level state.
20
- * Populated by initSegmentsSync before React renders.
21
- * Used by useState initializer during SSR.
22
- */
23
- let ssrSegmentOrder: string[] = [];
24
- let ssrPathname: string = "/";
25
- let ssrParams: Record<string, string> = {};
26
-
27
- /**
28
- * Filter segment IDs to only include routes and layouts.
29
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
30
- */
31
- function filterSegmentOrder(matched: string[]): string[] {
32
- return matched.filter((id) => {
33
- if (id.includes(".@")) return false;
34
- if (/D\d+\./.test(id)) return false;
35
- return true;
36
- });
37
- }
38
-
39
- /**
40
- * Initialize segments data synchronously for SSR.
41
- * Called before rendering to populate state for useState initializer.
42
- *
43
- * @param matched - Segment order from RSC metadata
44
- * @param pathname - Current pathname
45
- * @param params - Merged route params
46
- */
47
- export function initSegmentsSync(
48
- matched?: string[],
49
- pathname?: string,
50
- params?: Record<string, string>,
51
- ): void {
52
- ssrSegmentOrder = filterSegmentOrder(matched ?? []);
53
- ssrPathname = pathname ?? "/";
54
- ssrParams = params ?? {};
55
- }
56
-
57
- /**
58
- * Get SSR params for use-params hook initialization.
59
- */
60
- export function getSsrParams(): Record<string, string> {
61
- return ssrParams;
62
- }
63
-
64
- /**
65
- * Get SSR pathname for use-pathname hook initialization.
66
- */
67
- export function getSsrPathname(): string {
68
- return ssrPathname;
69
- }
70
-
71
- /**
72
- * Shallow equality check for selector results
73
- */
74
- function shallowEqual<T>(a: T, b: T): boolean {
75
- if (Object.is(a, b)) return true;
76
- if (
77
- typeof a !== "object" ||
78
- a === null ||
79
- typeof b !== "object" ||
80
- b === null
81
- ) {
82
- return false;
83
- }
84
- const keysA = Object.keys(a);
85
- const keysB = Object.keys(b);
86
- if (keysA.length !== keysB.length) return false;
87
- for (const key of keysA) {
88
- if (
89
- !Object.hasOwn(b, key) ||
90
- !Object.is((a as any)[key], (b as any)[key])
91
- ) {
92
- return false;
93
- }
94
- }
95
- return true;
96
- }
97
-
98
19
  /**
99
20
  * Parse pathname into path segments
100
21
  * /shop/products/123 → ["shop", "products", "123"]
@@ -117,18 +38,6 @@ function buildSegmentsState(
117
38
  };
118
39
  }
119
40
 
120
- /**
121
- * Build SSR state from module-level variables
122
- */
123
- function buildSsrState(): SegmentsState {
124
- const location = new URL(ssrPathname, "http://localhost");
125
- return {
126
- path: parsePathname(ssrPathname),
127
- segmentIds: ssrSegmentOrder,
128
- location,
129
- };
130
- }
131
-
132
41
  /**
133
42
  * Hook to access current route segments with optional selector for performance
134
43
  *
@@ -152,50 +61,99 @@ export function useSegments<T>(
152
61
  ): T | SegmentsState {
153
62
  const ctx = useContext(NavigationStoreContext);
154
63
 
155
- // Build initial state from SSR module state or event controller
64
+ // Build initial state from event controller when context exists.
65
+ // Inlined rather than calling recompute() because the segmentsCache ref
66
+ // is not yet initialized during the useState initializer.
156
67
  const [state, setState] = useState<T | SegmentsState>(() => {
157
- // During SSR or when no context, use module-level SSR state
158
- if (typeof document === "undefined" || !ctx) {
159
- const ssrState = buildSsrState();
160
- return selector ? selector(ssrState) : ssrState;
68
+ if (!ctx) {
69
+ const fallbackLocation = new URL("/", "http://localhost");
70
+ const fallbackState = buildSegmentsState(fallbackLocation, []);
71
+ return selector ? selector(fallbackState) : fallbackState;
161
72
  }
162
- // On client with context, use event controller state
163
- const navState = ctx.eventController.getState();
73
+ const location = ctx.eventController.getLocation();
164
74
  const handleState = ctx.eventController.getHandleState();
165
75
  const segmentsState = buildSegmentsState(
166
- navState.location as URL,
76
+ location as URL,
167
77
  handleState.segmentOrder,
168
78
  );
169
79
  return selector ? selector(segmentsState) : segmentsState;
170
80
  });
171
81
 
172
82
  const prevState = useRef(state);
83
+ const selectorRef = useRef(selector);
84
+ selectorRef.current = selector;
85
+
86
+ // Track selector identity to detect when the selector function changes.
87
+ // Only then do we eagerly recompute during render to avoid staleness.
88
+ // Without this guard, no-selector mode causes infinite re-renders because
89
+ // buildSegmentsState creates fresh arrays that fail Object.is checks.
90
+ const prevSelectorIdentity = useRef(selector);
91
+
92
+ // Cache SegmentsState to stabilize nested references (path, segmentIds
93
+ // arrays) so selectors returning composite values don't cause spurious
94
+ // render-time setState calls.
95
+ const segmentsCache = useRef<{
96
+ location: URL;
97
+ segmentOrder: string[];
98
+ state: SegmentsState;
99
+ } | null>(null);
100
+
101
+ // Recompute selected value from current store state and apply selector.
102
+ // Shared by the render-time eager check and the subscription callback.
103
+ function recompute(
104
+ sel: ((state: SegmentsState) => T) | undefined,
105
+ ): T | SegmentsState {
106
+ const location = ctx!.eventController.getLocation();
107
+ const handleState = ctx!.eventController.getHandleState();
108
+
109
+ // Reuse cached state when inputs haven't changed by reference,
110
+ // keeping array/object references stable for composite selectors.
111
+ const cache = segmentsCache.current;
112
+ let segmentsState: SegmentsState;
113
+ if (
114
+ cache &&
115
+ cache.location === location &&
116
+ cache.segmentOrder === handleState.segmentOrder
117
+ ) {
118
+ segmentsState = cache.state;
119
+ } else {
120
+ segmentsState = buildSegmentsState(
121
+ location as URL,
122
+ handleState.segmentOrder,
123
+ );
124
+ segmentsCache.current = {
125
+ location: location as URL,
126
+ segmentOrder: handleState.segmentOrder,
127
+ state: segmentsState,
128
+ };
129
+ }
130
+ return sel ? sel(segmentsState) : segmentsState;
131
+ }
132
+
133
+ if (ctx && selector !== prevSelectorIdentity.current) {
134
+ prevSelectorIdentity.current = selector;
135
+ const nextSelected = recompute(selector);
136
+ if (!shallowEqual(nextSelected, prevState.current)) {
137
+ prevState.current = nextSelected;
138
+ setState(nextSelected);
139
+ }
140
+ }
173
141
 
174
- // Subscribe to both navigation state and handle state changes
142
+ // Subscribe to store changes. The eager block above handles selector
143
+ // changes and SSR drift, so no initial updateState() call is needed.
175
144
  useEffect(() => {
176
145
  if (!ctx) {
177
146
  return;
178
147
  }
179
148
 
180
149
  const updateState = () => {
181
- const navState = ctx.eventController.getState();
182
- const handleState = ctx.eventController.getHandleState();
183
- const segmentsState = buildSegmentsState(
184
- navState.location as URL,
185
- handleState.segmentOrder,
186
- );
187
- const nextSelected = selector ? selector(segmentsState) : segmentsState;
188
-
150
+ const nextSelected = recompute(selectorRef.current);
189
151
  if (!shallowEqual(nextSelected, prevState.current)) {
190
152
  prevState.current = nextSelected;
191
153
  setState(nextSelected);
192
154
  }
193
155
  };
194
156
 
195
- // Initial update in case SSR state differs from client state
196
- updateState();
197
-
198
- // Subscribe to both state sources
199
157
  const unsubscribeNav = ctx.eventController.subscribe(updateState);
200
158
  const unsubscribeHandles =
201
159
  ctx.eventController.subscribeToHandles(updateState);
@@ -204,7 +162,10 @@ export function useSegments<T>(
204
162
  unsubscribeNav();
205
163
  unsubscribeHandles();
206
164
  };
207
- }, [selector]);
165
+ // Stable subscription: selector changes are handled via selectorRef,
166
+ // state comparison uses prevState ref. No re-subscribe needed.
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, []);
208
169
 
209
170
  return state as T | SegmentsState;
210
171
  }
@@ -0,0 +1,73 @@
1
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
2
+
3
+ type HeaderResult = { url: string } | "blocked" | null;
4
+
5
+ /**
6
+ * Extract and validate an RSC response header URL (X-RSC-Reload, X-RSC-Redirect).
7
+ * Returns { url } if valid, "blocked" if present but invalid origin, null if absent.
8
+ */
9
+ export function extractRscHeaderUrl(
10
+ response: Response,
11
+ header: string,
12
+ ): HeaderResult {
13
+ const raw = response.headers.get(header);
14
+ if (!raw) return null;
15
+ const url = validateRedirectOrigin(raw, window.location.origin);
16
+ return url ? { url } : "blocked";
17
+ }
18
+
19
+ /**
20
+ * Empty 200 response that won't choke Flight parsing.
21
+ * Used when a header URL is blocked by origin validation.
22
+ */
23
+ export function emptyResponse(): Response {
24
+ return new Response(null, { status: 200 });
25
+ }
26
+
27
+ /**
28
+ * Tee a response body for RSC parsing and stream completion tracking.
29
+ * Returns a new Response with one branch; the other is consumed to detect
30
+ * end-of-stream, calling onComplete when done.
31
+ *
32
+ * If the response has no body, onComplete fires synchronously.
33
+ * If signal is provided, an abort cancels the tracking reader.
34
+ */
35
+ export function teeWithCompletion(
36
+ response: Response,
37
+ onComplete: () => void,
38
+ signal?: AbortSignal,
39
+ ): Response {
40
+ if (!response.body) {
41
+ onComplete();
42
+ return response;
43
+ }
44
+
45
+ const [rscStream, trackingStream] = response.body.tee();
46
+
47
+ (async () => {
48
+ const reader = trackingStream.getReader();
49
+ const onAbort = signal ? reader.cancel.bind(reader) : undefined;
50
+ if (onAbort) signal!.addEventListener("abort", onAbort, { once: true });
51
+ try {
52
+ while (true) {
53
+ const { done } = await reader.read();
54
+ if (done) break;
55
+ }
56
+ } finally {
57
+ if (onAbort) signal!.removeEventListener("abort", onAbort);
58
+ reader.releaseLock();
59
+ onComplete();
60
+ }
61
+ })().catch((error) => {
62
+ if (!signal?.aborted) {
63
+ console.error("[Browser] Error reading tracking stream:", error);
64
+ }
65
+ onComplete();
66
+ });
67
+
68
+ return new Response(rscStream, {
69
+ headers: response.headers,
70
+ status: response.status,
71
+ statusText: response.statusText,
72
+ });
73
+ }