@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
@@ -4,40 +4,58 @@ import React, {
4
4
  forwardRef,
5
5
  useCallback,
6
6
  useContext,
7
+ useEffect,
7
8
  useRef,
8
9
  type ForwardRefExoticComponent,
9
10
  type RefAttributes,
10
11
  } from "react";
11
12
  import { NavigationStoreContext } from "./context.js";
12
13
  import { LinkContext } from "./use-link-status.js";
13
- import { prefetchUrl } from "./prefetch.js";
14
14
  import type { NavigateOptions } from "../types.js";
15
+ import { isHashOnlyNavigation } from "../link-interceptor.js";
15
16
  import {
16
- type LocationStateEntry,
17
17
  isLocationStateEntry,
18
+ type LocationStateEntry,
18
19
  resolveLocationStateEntries,
19
20
  } from "./location-state.js";
20
21
 
21
22
  /**
22
- * State value or getter function for just-in-time state resolution (legacy)
23
+ * State prop type for Link component.
24
+ * - LocationStateEntry[]: Type-safe state entries via createLocationState()
25
+ * - StateOrGetter: Plain state object or click-time getter function
26
+ * - Record<string, unknown>: Plain state object passed to history.pushState
23
27
  */
24
28
  export type StateOrGetter<T = unknown> = T | (() => T);
25
29
 
26
- /**
27
- * State prop type for Link component
28
- * - LocationStateEntry[]: Type-safe state entries (always lazy)
29
- * - StateOrGetter: Legacy format for backwards compatibility
30
- */
31
- export type LinkState = LocationStateEntry[] | StateOrGetter;
30
+ export type LinkState =
31
+ | LocationStateEntry[]
32
+ | StateOrGetter<Record<string, unknown>>;
33
+
34
+ import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
35
+ import {
36
+ observeForPrefetch,
37
+ unobserveForPrefetch,
38
+ } from "../prefetch/observer.js";
39
+
40
+ // Touch device detection for adaptive strategy.
41
+ // Checked once at module load (Link.tsx is "use client", runs only in browser).
42
+ const isTouchDevice =
43
+ typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
32
44
 
33
45
  /**
34
46
  * Prefetch strategy for the Link component
35
- * - "hover": Prefetch on mouse enter (uses native <link rel="prefetch">)
36
- * - "viewport": Prefetch when link enters viewport (not yet implemented)
37
- * - "hybrid": Hover on desktop, viewport on mobile (not yet implemented)
47
+ * - "hover": Prefetch on mouse enter (direct, no queue)
48
+ * - "viewport": Prefetch when link enters viewport (queued, waits for idle)
49
+ * - "render": Prefetch on component mount regardless of visibility (queued, waits for idle)
50
+ * - "adaptive": Hover on pointer devices, viewport on touch devices
38
51
  * - "none": No prefetching (default)
39
52
  */
40
- export type PrefetchStrategy = "hover" | "viewport" | "hybrid" | "none";
53
+ export type PrefetchStrategy =
54
+ | "hover"
55
+ | "viewport"
56
+ | "render"
57
+ | "adaptive"
58
+ | "none";
41
59
 
42
60
  /**
43
61
  * Link component props
@@ -74,16 +92,29 @@ export interface LinkProps extends Omit<
74
92
  * @example
75
93
  * ```tsx
76
94
  * // Type-safe state with createLocationState (recommended)
77
- * const ProductState = createLocationState((p: Product) => ({ name: p.name }));
78
- * <Link to="/product" state={[ProductState(product)]}>View</Link>
95
+ * const ProductState = createLocationState<{ name: string; price: number }>();
96
+ * <Link to="/product" state={[ProductState({ name: product.name, price: product.price })]}>
97
+ * View
98
+ * </Link>
99
+ *
100
+ * // Type-safe just-in-time state (getter called at click time, not render time).
101
+ * // Must be in a client component -- getter can't cross the RSC boundary.
102
+ * <Link
103
+ * to="/product"
104
+ * state={[ProductState(() => ({ name: product.name, price: product.price }))]}
105
+ * >
106
+ * View
107
+ * </Link>
79
108
  *
80
109
  * // Multiple typed states
81
- * <Link to="/checkout" state={[ProductState(p), CartState(c)]}>Checkout</Link>
110
+ * <Link to="/checkout" state={[ProductState({ name: p.name, price: p.price }), CartState(c)]}>
111
+ * Checkout
112
+ * </Link>
82
113
  *
83
- * // Legacy: static state
114
+ * // Plain static state
84
115
  * <Link to="/product" state={{ from: "list" }}>View</Link>
85
116
  *
86
- * // Legacy: dynamic state (called at click time)
117
+ * // Plain just-in-time state (called at click time, requires client component)
87
118
  * <Link to="/product" state={() => ({ scrollY: window.scrollY })}>View</Link>
88
119
  * ```
89
120
  */
@@ -150,6 +181,25 @@ export const Link: ForwardRefExoticComponent<
150
181
  const ctx = useContext(NavigationStoreContext);
151
182
  const isExternal = isExternalUrl(to);
152
183
 
184
+ // Resolve adaptive: viewport on touch devices, hover on pointer devices
185
+ const resolvedStrategy =
186
+ prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
187
+
188
+ // Internal ref for viewport observation; merge with forwarded ref
189
+ const internalRef = useRef<HTMLAnchorElement | null>(null);
190
+ const setRef = useCallback(
191
+ (node: HTMLAnchorElement | null) => {
192
+ internalRef.current = node;
193
+ if (typeof ref === "function") {
194
+ ref(node);
195
+ } else if (ref) {
196
+ (ref as React.MutableRefObject<HTMLAnchorElement | null>).current =
197
+ node;
198
+ }
199
+ },
200
+ [ref],
201
+ );
202
+
153
203
  // Use ref to always get the latest state/getter without adding to useCallback deps
154
204
  // This enables just-in-time state resolution without causing re-renders
155
205
  const stateRef = useRef(state);
@@ -180,49 +230,109 @@ export const Link: ForwardRefExoticComponent<
180
230
  const target = (e.currentTarget as HTMLAnchorElement).target;
181
231
  if (target && target !== "_self") return;
182
232
 
233
+ // Hash-only navigation: let the browser handle anchor scrolling natively.
234
+ if (isHashOnlyNavigation(e.currentTarget as HTMLAnchorElement)) {
235
+ return;
236
+ }
237
+
238
+ // No navigation context (outside provider): fall back to native navigation.
239
+ if (!ctx?.navigate) {
240
+ return;
241
+ }
242
+
183
243
  // Prevent default and use SPA navigation
184
244
  e.preventDefault();
185
245
  // Stop propagation to prevent link-interceptor from also handling this
186
246
  e.stopPropagation();
187
247
 
188
- if (ctx?.navigate) {
189
- // Resolve state just-in-time based on format
190
- let resolvedState: unknown;
191
- const currentState = stateRef.current;
192
-
193
- if (
194
- Array.isArray(currentState) &&
195
- currentState.length > 0 &&
196
- isLocationStateEntry(currentState[0])
197
- ) {
198
- // Type-safe LocationStateEntry[] - resolve each entry into keyed object
199
- resolvedState = resolveLocationStateEntries(
200
- currentState as LocationStateEntry[],
201
- );
202
- } else if (typeof currentState === "function") {
203
- // Legacy getter function
204
- resolvedState = currentState();
205
- } else {
206
- // Legacy static value
207
- resolvedState = currentState;
208
- }
248
+ const currentState = stateRef.current;
249
+ let resolvedState: unknown;
209
250
 
210
- ctx.navigate(to, { replace, scroll, state: resolvedState });
251
+ if (
252
+ Array.isArray(currentState) &&
253
+ currentState.length > 0 &&
254
+ isLocationStateEntry(currentState[0])
255
+ ) {
256
+ resolvedState = resolveLocationStateEntries(
257
+ currentState as LocationStateEntry[],
258
+ );
259
+ } else if (typeof currentState === "function") {
260
+ resolvedState = currentState();
261
+ } else if (currentState != null) {
262
+ resolvedState = currentState;
211
263
  }
264
+
265
+ ctx.navigate(to, { replace, scroll, state: resolvedState });
212
266
  },
213
267
  [to, isExternal, reloadDocument, replace, scroll, ctx, onClick],
214
268
  );
215
269
 
216
270
  const handleMouseEnter = useCallback(() => {
217
- if (prefetch === "hover" && !isExternal && ctx?.store) {
271
+ if (resolvedStrategy === "hover" && !isExternal && ctx?.store) {
272
+ const segmentState = ctx.store.getSegmentState();
273
+ prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
274
+ }
275
+ }, [resolvedStrategy, to, isExternal, ctx]);
276
+
277
+ // Viewport/render prefetch: waits for idle before starting,
278
+ // uses concurrency-limited queue to avoid flooding.
279
+ useEffect(() => {
280
+ if (isExternal || !ctx?.store) return;
281
+ const isViewport = resolvedStrategy === "viewport";
282
+ const isRender = resolvedStrategy === "render";
283
+ if (!isViewport && !isRender) return;
284
+
285
+ let cancelled = false;
286
+ let unsubIdle: (() => void) | undefined;
287
+ let observedElement: Element | null = null;
288
+
289
+ const triggerPrefetch = () => {
290
+ if (cancelled) return;
218
291
  const segmentState = ctx.store.getSegmentState();
219
- prefetchUrl(to, segmentState.currentSegmentIds);
292
+ prefetchQueued(to, segmentState.currentSegmentIds, ctx.version);
293
+ };
294
+
295
+ // Schedule prefetch only when the app is idle (no navigation/streaming).
296
+ // This avoids competing with hydration and active navigation fetches.
297
+ const scheduleWhenIdle = (callback: () => void) => {
298
+ const state = ctx.eventController.getState();
299
+ if (state.state === "idle" && !state.isStreaming) {
300
+ callback();
301
+ return;
302
+ }
303
+ const unsub = ctx.eventController.subscribe(() => {
304
+ const s = ctx.eventController.getState();
305
+ if (s.state === "idle" && !s.isStreaming) {
306
+ unsub();
307
+ callback();
308
+ }
309
+ });
310
+ unsubIdle = unsub;
311
+ };
312
+
313
+ if (isRender) {
314
+ scheduleWhenIdle(triggerPrefetch);
315
+ } else if (isViewport) {
316
+ const element = internalRef.current;
317
+ if (!element) return;
318
+ observedElement = element;
319
+ observeForPrefetch(element, () => {
320
+ scheduleWhenIdle(triggerPrefetch);
321
+ });
220
322
  }
221
- }, [prefetch, to, isExternal, ctx]);
323
+
324
+ return () => {
325
+ cancelled = true;
326
+ unsubIdle?.();
327
+ if (isViewport && observedElement) {
328
+ unobserveForPrefetch(observedElement);
329
+ }
330
+ };
331
+ }, [resolvedStrategy, to, isExternal, ctx]);
222
332
 
223
333
  return (
224
334
  <a
225
- ref={ref}
335
+ ref={setRef}
226
336
  href={to}
227
337
  onClick={handleClick}
228
338
  onMouseEnter={handleMouseEnter}
@@ -22,7 +22,9 @@ import type { EventController } from "../event-controller.js";
22
22
  import { RootErrorBoundary } from "../../root-error-boundary.js";
23
23
  import type { HandleData } from "../types.js";
24
24
  import { ThemeProvider } from "../../theme/ThemeProvider.js";
25
+ import { NonceContext } from "./nonce-context.js";
25
26
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
27
+ import { cancelAllPrefetches } from "../prefetch/queue.js";
26
28
 
27
29
  /**
28
30
  * Process handles from an async generator, updating the event controller
@@ -126,6 +128,12 @@ export interface NavigationProviderProps {
126
128
  * When true, keeps TLS alive by sending HEAD requests after idle periods.
127
129
  */
128
130
  warmupEnabled?: boolean;
131
+
132
+ /**
133
+ * App version from server payload (stable, immutable).
134
+ * Forwarded to prefetch requests for version mismatch detection.
135
+ */
136
+ version?: string;
129
137
  }
130
138
 
131
139
  /**
@@ -157,6 +165,7 @@ export function NavigationProvider({
157
165
  themeConfig,
158
166
  initialTheme,
159
167
  warmupEnabled,
168
+ version,
160
169
  }: NavigationProviderProps): ReactNode {
161
170
  // Track current payload for rendering (this triggers re-renders)
162
171
  const [payload, setPayload] = useState(initialPayload);
@@ -185,6 +194,7 @@ export function NavigationProvider({
185
194
  eventController,
186
195
  navigate,
187
196
  refresh,
197
+ version,
188
198
  }),
189
199
  [],
190
200
  );
@@ -276,6 +286,21 @@ export function NavigationProvider({
276
286
  };
277
287
  }, [warmupEnabled]);
278
288
 
289
+ // Cancel speculative prefetches when navigation starts.
290
+ // Viewport/render prefetches should not compete with navigation fetches.
291
+ useEffect(() => {
292
+ let wasIdle = true;
293
+ const unsub = eventController.subscribe(() => {
294
+ const state = eventController.getState();
295
+ const isIdle = state.state === "idle" && !state.isStreaming;
296
+ if (wasIdle && !isIdle) {
297
+ cancelAllPrefetches();
298
+ }
299
+ wasIdle = isIdle;
300
+ });
301
+ return unsub;
302
+ }, [eventController]);
303
+
279
304
  // Subscribe to UI updates (for re-rendering the tree)
280
305
  useEffect(() => {
281
306
  const unsubscribe = store.onUpdate((update) => {
@@ -346,6 +371,13 @@ export function NavigationProvider({
346
371
  );
347
372
  }
348
373
 
374
+ // Match SSR tree shape: NonceContext.Provider is always present so
375
+ // hydration sees the same component tree. Value is undefined on the
376
+ // client — CSP nonces are a server-side HTML concern.
377
+ content = (
378
+ <NonceContext.Provider value={undefined}>{content}</NonceContext.Provider>
379
+ );
380
+
349
381
  return (
350
382
  <NavigationStoreContext.Provider value={contextValue}>
351
383
  {content}
@@ -41,6 +41,12 @@ export interface NavigationStoreContextValue {
41
41
  * @returns Promise that resolves when refresh is complete
42
42
  */
43
43
  refresh: () => Promise<void>;
44
+
45
+ /**
46
+ * App version from server payload (stable, immutable).
47
+ * Used in prefetch requests for version mismatch detection.
48
+ */
49
+ version: string | undefined;
44
50
  }
45
51
 
46
52
  /**
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Filter segment IDs to only include routes and layouts.
3
+ * Excludes parallels (contain .@) and loaders (contain D followed by digit).
4
+ */
5
+ export function filterSegmentOrder(matched: string[]): string[] {
6
+ return matched.filter((id) => {
7
+ if (id.includes(".@")) return false;
8
+ if (/D\d+\./.test(id)) return false;
9
+ return true;
10
+ });
11
+ }
@@ -15,14 +15,10 @@ export { useParams } from "./use-params.js";
15
15
  export { useAction, type TrackedActionState } from "./use-action.js";
16
16
 
17
17
  // Segments state hook
18
- export {
19
- useSegments,
20
- initSegmentsSync,
21
- type SegmentsState,
22
- } from "./use-segments.js";
18
+ export { useSegments, type SegmentsState } from "./use-segments.js";
23
19
 
24
20
  // Handle data hook
25
- export { useHandle, initHandleDataSync } from "./use-handle.js";
21
+ export { useHandle } from "./use-handle.js";
26
22
 
27
23
  // Client cache controls hook
28
24
  export {
@@ -4,11 +4,14 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Internal entry representing a state value with its unique key
7
+ * Internal entry representing a state value with its unique key.
8
+ * When __rsc_ls_lazy is true, __rsc_ls_value holds a getter function
9
+ * that is called at navigation time (not at entry creation time).
8
10
  */
9
11
  export interface LocationStateEntry {
10
12
  readonly __rsc_ls_key: string;
11
13
  readonly __rsc_ls_value: unknown;
14
+ readonly __rsc_ls_lazy?: boolean;
12
15
  }
13
16
 
14
17
  /**
@@ -55,6 +58,13 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
55
58
  * // Use in Link
56
59
  * <Link to="/product/123" state={[ProductState({ name: "Widget", price: 9.99 })]}>
57
60
  *
61
+ * // Just-in-time typed state (getter called at click time, not render time).
62
+ * // Must be in a client component — the getter function can't cross the RSC boundary.
63
+ * <Link
64
+ * to="/product/123"
65
+ * state={[ProductState(() => ({ name: product.name, price: product.price }))]}
66
+ * >
67
+ *
58
68
  * // Read with hook (reactive)
59
69
  * const product = useLocationState(ProductState);
60
70
  *
@@ -69,7 +79,7 @@ export function createLocationState<TState>(
69
79
  let _key: string | undefined;
70
80
 
71
81
  function getKey(): string {
72
- if (!_key && process.env.NODE_ENV !== "production") {
82
+ if (!_key && process.env.NODE_ENV === "development") {
73
83
  throw new Error(
74
84
  "[rsc-router] createLocationState key not set. " +
75
85
  "Make sure the exposeInternalIds Vite plugin is enabled and " +
@@ -79,14 +89,20 @@ export function createLocationState<TState>(
79
89
  return _key!;
80
90
  }
81
91
 
82
- const fn = (stateOrGetter: TState | (() => TState)): LocationStateEntry => ({
83
- __rsc_ls_key: getKey(),
84
- // Resolve getter immediately - lazy evaluation happens via Link's stateRef pattern
85
- __rsc_ls_value:
86
- typeof stateOrGetter === "function"
87
- ? (stateOrGetter as () => TState)()
88
- : stateOrGetter,
89
- });
92
+ const fn = (stateOrGetter: TState | (() => TState)): LocationStateEntry => {
93
+ if (typeof stateOrGetter === "function") {
94
+ // Store getter as-is; resolved at navigation time by resolveLocationStateEntries()
95
+ return {
96
+ __rsc_ls_key: getKey(),
97
+ __rsc_ls_value: stateOrGetter,
98
+ __rsc_ls_lazy: true,
99
+ };
100
+ }
101
+ return {
102
+ __rsc_ls_key: getKey(),
103
+ __rsc_ls_value: stateOrGetter,
104
+ };
105
+ };
90
106
 
91
107
  // Use defineProperty for __rsc_ls_key to avoid Object.assign evaluating
92
108
  // the getter during construction (before the Vite plugin sets the key).
@@ -138,7 +154,9 @@ export function resolveLocationStateEntries(
138
154
  ): Record<string, unknown> {
139
155
  const result: Record<string, unknown> = {};
140
156
  for (const entry of entries) {
141
- result[entry.__rsc_ls_key] = entry.__rsc_ls_value;
157
+ result[entry.__rsc_ls_key] = entry.__rsc_ls_lazy
158
+ ? (entry.__rsc_ls_value as () => unknown)()
159
+ : entry.__rsc_ls_value;
142
160
  }
143
161
  return result;
144
162
  }
@@ -22,7 +22,7 @@ export {
22
22
  *
23
23
  * Overloaded:
24
24
  * - With definition: Returns typed state from the specific key
25
- * - With type param only: Returns legacy state from history.state.state (backwards compat)
25
+ * - With type param only: Returns plain state from history.state.state
26
26
  *
27
27
  * @example
28
28
  * ```typescript
@@ -34,8 +34,8 @@ export {
34
34
  * const FlashMsg = createLocationState<{ text: string }>({ flash: true });
35
35
  * const flash = useLocationState(FlashMsg);
36
36
  *
37
- * // Legacy typed access (backwards compatible)
38
- * const legacyState = useLocationState<{ from?: string }>();
37
+ * // Plain state access (reads from history.state.state)
38
+ * const state = useLocationState<{ from?: string }>();
39
39
  * ```
40
40
  */
41
41
  export function useLocationState<TArgs extends unknown[], TState>(
@@ -53,7 +53,7 @@ export function useLocationState<TArgs extends unknown[], TState>(
53
53
  if (key) {
54
54
  return window.history.state?.[key] as TState | undefined;
55
55
  }
56
- // Legacy: return history.state.state for backwards compatibility
56
+ // Plain state: stored under history.state.state
57
57
  return window.history.state?.state as TState | undefined;
58
58
  });
59
59
 
@@ -80,6 +80,8 @@ export function useLocationState<TArgs extends unknown[], TState>(
80
80
  } else {
81
81
  setState(val);
82
82
  }
83
+ } else {
84
+ setState(window.history.state?.state as TState | undefined);
83
85
  }
84
86
  };
85
87
 
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Context for CSP nonce propagation to client components during SSR.
5
+ *
6
+ * The SSR renderer wraps the tree with NonceContext.Provider so that
7
+ * client components (e.g. MetaTags) can apply nonces to inline scripts.
8
+ * On the browser side, no provider is needed — the default undefined
9
+ * is correct since CSP nonces are a server-side HTML concern.
10
+ */
11
+
12
+ import { createContext, useContext, type Context } from "react";
13
+
14
+ export const NonceContext: Context<string | undefined> = createContext<
15
+ string | undefined
16
+ >(undefined);
17
+
18
+ /**
19
+ * Read the CSP nonce during SSR. Returns undefined on the client.
20
+ */
21
+ export function useNonce(): string | undefined {
22
+ return useContext(NonceContext);
23
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shallow equality check for selector results.
3
+ * Uses Object.is for value comparison (handles NaN and +-0 correctly).
4
+ */
5
+ export function shallowEqual<T>(a: T, b: T): boolean {
6
+ if (Object.is(a, b)) return true;
7
+ if (
8
+ typeof a !== "object" ||
9
+ a === null ||
10
+ typeof b !== "object" ||
11
+ b === null
12
+ ) {
13
+ return false;
14
+ }
15
+ const keysA = Object.keys(a);
16
+ const keysB = Object.keys(b);
17
+ if (keysA.length !== keysB.length) return false;
18
+ for (const key of keysA) {
19
+ if (
20
+ !Object.hasOwn(b, key) ||
21
+ !Object.is((a as any)[key], (b as any)[key])
22
+ ) {
23
+ return false;
24
+ }
25
+ }
26
+ return true;
27
+ }
@@ -9,7 +9,8 @@ import {
9
9
  startTransition,
10
10
  } from "react";
11
11
  import { NavigationStoreContext } from "./context.js";
12
- import type { TrackedActionState, ActionLifecycleState } from "../types.js";
12
+ import { shallowEqual } from "./shallow-equal.js";
13
+ import type { TrackedActionState } from "../types.js";
13
14
  import { invariant } from "../../errors.js";
14
15
 
15
16
  /**
@@ -126,6 +127,11 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
126
127
  * const error = useAction(addToCart, state => state.error);
127
128
  * ```
128
129
  *
130
+ * @note The selector is expected to be stable for a given hook instance.
131
+ * This hook tracks one projection of one action. Changing selector semantics
132
+ * for the same action ID without a new action event is not a supported pattern;
133
+ * use separate useAction() subscriptions if you need different projections.
134
+ *
129
135
  * @note Actions passed as props from server components lose their metadata
130
136
  * during RSC serialization. Use a string action name or import directly.
131
137
  */
@@ -161,7 +167,10 @@ export function useAction<T>(
161
167
  T | TrackedActionState
162
168
  >(null!);
163
169
 
164
- // Memoize the selector to avoid unnecessary re-subscriptions
170
+ // Ref keeps the latest selector for subscription callbacks without
171
+ // re-subscribing on every render. Selector changes themselves are not
172
+ // treated as a reactive input; this hook expects a stable selector and
173
+ // represents one subscription/projection for one action.
165
174
  const selectorRef = useRef(selector);
166
175
  selectorRef.current = selector;
167
176
 
@@ -169,6 +178,17 @@ export function useAction<T>(
169
178
  useEffect(() => {
170
179
  if (!ctx) return;
171
180
 
181
+ // Sync current state for the (possibly new) actionId so that switching
182
+ // actions on an idle page doesn't leave stale data from the old action.
183
+ const currentState = ctx.eventController.getActionState(actionId);
184
+ const currentSelected = selectorRef.current
185
+ ? selectorRef.current(currentState)
186
+ : currentState;
187
+ if (!shallowEqual(currentSelected, prevSelected.current)) {
188
+ prevSelected.current = currentSelected;
189
+ setBaseState(currentSelected);
190
+ }
191
+
172
192
  // Subscribe to action-specific updates
173
193
  const unsubscribe = ctx.eventController.subscribeToAction(
174
194
  actionId,
@@ -177,7 +197,7 @@ export function useAction<T>(
177
197
  ? selectorRef.current(state)
178
198
  : state;
179
199
 
180
- if (!isShallowEqual(selectedState, prevSelected.current)) {
200
+ if (!shallowEqual(selectedState, prevSelected.current)) {
181
201
  prevSelected.current = selectedState;
182
202
  setBaseState(selectedState);
183
203
  startTransition(() => {
@@ -195,46 +215,4 @@ export function useAction<T>(
195
215
  return (optimisticState ?? baseState) as T | TrackedActionState;
196
216
  }
197
217
 
198
- function isShallowEqual<T, U>(selectedState: T, baseState: U): boolean {
199
- // If references are equal, they're shallow equal
200
- //@ts-expect-error -- TS doesn't like comparing generics
201
- if (selectedState === baseState) {
202
- return true;
203
- }
204
-
205
- // If either is null/undefined and they're not equal, they're not shallow equal
206
- if (selectedState == null || baseState == null) {
207
- return false;
208
- }
209
-
210
- // If types are different, they're not shallow equal
211
- if (typeof selectedState !== typeof baseState) {
212
- return false;
213
- }
214
-
215
- // For primitives, === comparison is sufficient (already checked above)
216
- if (typeof selectedState !== "object") {
217
- return false;
218
- }
219
-
220
- // For objects, compare keys and values shallowly
221
- const keysA = Object.keys(selectedState as object);
222
- const keysB = Object.keys(baseState as object);
223
-
224
- if (keysA.length !== keysB.length) {
225
- return false;
226
- }
227
-
228
- for (const key of keysA) {
229
- if (
230
- !Object.prototype.hasOwnProperty.call(baseState, key) ||
231
- (selectedState as any)[key] !== (baseState as any)[key]
232
- ) {
233
- return false;
234
- }
235
- }
236
-
237
- return true;
238
- }
239
-
240
218
  export type { TrackedActionState };
@@ -46,10 +46,12 @@ export interface ClientCacheControls {
46
46
  export function useClientCache(): ClientCacheControls {
47
47
  const ctx = useContext(NavigationStoreContext);
48
48
 
49
+ if (!ctx) {
50
+ throw new Error("useClientCache must be used within NavigationProvider");
51
+ }
52
+
49
53
  const clear = useCallback(() => {
50
- if (ctx?.store) {
51
- ctx.store.clearHistoryCache();
52
- }
54
+ ctx.store.clearHistoryCache();
53
55
  }, [ctx]);
54
56
 
55
57
  return { clear };