@rangojs/router 0.0.0-experimental.7 → 0.0.0-experimental.71

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 (307) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +942 -4
  3. package/dist/bin/rango.js +1689 -0
  4. package/dist/vite/index.js +4951 -930
  5. package/package.json +70 -60
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +294 -0
  8. package/skills/caching/SKILL.md +93 -23
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +12 -8
  11. package/skills/document-cache/SKILL.md +18 -16
  12. package/skills/fonts/SKILL.md +167 -0
  13. package/skills/hooks/SKILL.md +334 -72
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +131 -8
  16. package/skills/layout/SKILL.md +100 -3
  17. package/skills/links/SKILL.md +92 -31
  18. package/skills/loader/SKILL.md +404 -44
  19. package/skills/middleware/SKILL.md +173 -34
  20. package/skills/mime-routes/SKILL.md +128 -0
  21. package/skills/parallel/SKILL.md +204 -1
  22. package/skills/prerender/SKILL.md +685 -0
  23. package/skills/rango/SKILL.md +85 -16
  24. package/skills/response-routes/SKILL.md +411 -0
  25. package/skills/route/SKILL.md +257 -14
  26. package/skills/router-setup/SKILL.md +210 -32
  27. package/skills/tailwind/SKILL.md +129 -0
  28. package/skills/theme/SKILL.md +9 -8
  29. package/skills/typesafety/SKILL.md +328 -89
  30. package/skills/use-cache/SKILL.md +324 -0
  31. package/src/__internal.ts +102 -4
  32. package/src/bin/rango.ts +321 -0
  33. package/src/browser/action-coordinator.ts +97 -0
  34. package/src/browser/action-response-classifier.ts +99 -0
  35. package/src/browser/app-version.ts +14 -0
  36. package/src/browser/event-controller.ts +92 -64
  37. package/src/browser/history-state.ts +80 -0
  38. package/src/browser/intercept-utils.ts +52 -0
  39. package/src/browser/link-interceptor.ts +24 -4
  40. package/src/browser/logging.ts +55 -0
  41. package/src/browser/merge-segment-loaders.ts +20 -12
  42. package/src/browser/navigation-bridge.ts +296 -558
  43. package/src/browser/navigation-client.ts +179 -69
  44. package/src/browser/navigation-store.ts +73 -55
  45. package/src/browser/navigation-transaction.ts +297 -0
  46. package/src/browser/network-error-handler.ts +61 -0
  47. package/src/browser/partial-update.ts +328 -313
  48. package/src/browser/prefetch/cache.ts +206 -0
  49. package/src/browser/prefetch/fetch.ts +150 -0
  50. package/src/browser/prefetch/observer.ts +65 -0
  51. package/src/browser/prefetch/policy.ts +48 -0
  52. package/src/browser/prefetch/queue.ts +160 -0
  53. package/src/browser/prefetch/resource-ready.ts +77 -0
  54. package/src/browser/rango-state.ts +112 -0
  55. package/src/browser/react/Link.tsx +230 -74
  56. package/src/browser/react/NavigationProvider.tsx +87 -11
  57. package/src/browser/react/context.ts +11 -0
  58. package/src/browser/react/filter-segment-order.ts +11 -0
  59. package/src/browser/react/index.ts +12 -12
  60. package/src/browser/react/location-state-shared.ts +95 -53
  61. package/src/browser/react/location-state.ts +60 -15
  62. package/src/browser/react/mount-context.ts +6 -1
  63. package/src/browser/react/nonce-context.ts +23 -0
  64. package/src/browser/react/shallow-equal.ts +27 -0
  65. package/src/browser/react/use-action.ts +29 -51
  66. package/src/browser/react/use-client-cache.ts +5 -3
  67. package/src/browser/react/use-handle.ts +30 -126
  68. package/src/browser/react/use-href.tsx +2 -2
  69. package/src/browser/react/use-link-status.ts +6 -5
  70. package/src/browser/react/use-navigation.ts +22 -63
  71. package/src/browser/react/use-params.ts +65 -0
  72. package/src/browser/react/use-pathname.ts +47 -0
  73. package/src/browser/react/use-router.ts +76 -0
  74. package/src/browser/react/use-search-params.ts +56 -0
  75. package/src/browser/react/use-segments.ts +80 -97
  76. package/src/browser/response-adapter.ts +73 -0
  77. package/src/browser/rsc-router.tsx +214 -58
  78. package/src/browser/scroll-restoration.ts +127 -52
  79. package/src/browser/segment-reconciler.ts +221 -0
  80. package/src/browser/segment-structure-assert.ts +16 -0
  81. package/src/browser/server-action-bridge.ts +510 -603
  82. package/src/browser/shallow.ts +6 -1
  83. package/src/browser/types.ts +141 -48
  84. package/src/browser/validate-redirect-origin.ts +29 -0
  85. package/src/build/generate-manifest.ts +235 -24
  86. package/src/build/generate-route-types.ts +39 -0
  87. package/src/build/index.ts +13 -0
  88. package/src/build/route-trie.ts +265 -0
  89. package/src/build/route-types/ast-helpers.ts +25 -0
  90. package/src/build/route-types/ast-route-extraction.ts +98 -0
  91. package/src/build/route-types/codegen.ts +102 -0
  92. package/src/build/route-types/include-resolution.ts +418 -0
  93. package/src/build/route-types/param-extraction.ts +48 -0
  94. package/src/build/route-types/per-module-writer.ts +128 -0
  95. package/src/build/route-types/router-processing.ts +618 -0
  96. package/src/build/route-types/scan-filter.ts +85 -0
  97. package/src/build/runtime-discovery.ts +231 -0
  98. package/src/cache/background-task.ts +34 -0
  99. package/src/cache/cache-key-utils.ts +44 -0
  100. package/src/cache/cache-policy.ts +125 -0
  101. package/src/cache/cache-runtime.ts +342 -0
  102. package/src/cache/cache-scope.ts +167 -309
  103. package/src/cache/cf/cf-cache-store.ts +571 -17
  104. package/src/cache/cf/index.ts +13 -3
  105. package/src/cache/document-cache.ts +116 -77
  106. package/src/cache/handle-capture.ts +81 -0
  107. package/src/cache/handle-snapshot.ts +41 -0
  108. package/src/cache/index.ts +1 -15
  109. package/src/cache/memory-segment-store.ts +191 -13
  110. package/src/cache/profile-registry.ts +73 -0
  111. package/src/cache/read-through-swr.ts +134 -0
  112. package/src/cache/segment-codec.ts +256 -0
  113. package/src/cache/taint.ts +153 -0
  114. package/src/cache/types.ts +72 -122
  115. package/src/client.rsc.tsx +3 -1
  116. package/src/client.tsx +105 -179
  117. package/src/component-utils.ts +4 -4
  118. package/src/components/DefaultDocument.tsx +5 -1
  119. package/src/context-var.ts +156 -0
  120. package/src/debug.ts +19 -9
  121. package/src/errors.ts +108 -2
  122. package/src/handle.ts +55 -29
  123. package/src/handles/MetaTags.tsx +73 -20
  124. package/src/handles/breadcrumbs.ts +66 -0
  125. package/src/handles/index.ts +1 -0
  126. package/src/handles/meta.ts +30 -13
  127. package/src/host/cookie-handler.ts +21 -15
  128. package/src/host/errors.ts +8 -8
  129. package/src/host/index.ts +4 -7
  130. package/src/host/pattern-matcher.ts +27 -27
  131. package/src/host/router.ts +61 -39
  132. package/src/host/testing.ts +8 -8
  133. package/src/host/types.ts +15 -7
  134. package/src/host/utils.ts +1 -1
  135. package/src/href-client.ts +119 -29
  136. package/src/index.rsc.ts +155 -19
  137. package/src/index.ts +223 -30
  138. package/src/internal-debug.ts +11 -0
  139. package/src/loader.rsc.ts +26 -157
  140. package/src/loader.ts +27 -10
  141. package/src/network-error-thrower.tsx +3 -1
  142. package/src/outlet-provider.tsx +45 -0
  143. package/src/prerender/param-hash.ts +37 -0
  144. package/src/prerender/store.ts +186 -0
  145. package/src/prerender.ts +524 -0
  146. package/src/reverse.ts +351 -0
  147. package/src/root-error-boundary.tsx +41 -29
  148. package/src/route-content-wrapper.tsx +7 -4
  149. package/src/route-definition/dsl-helpers.ts +982 -0
  150. package/src/route-definition/helper-factories.ts +200 -0
  151. package/src/route-definition/helpers-types.ts +434 -0
  152. package/src/route-definition/index.ts +55 -0
  153. package/src/route-definition/redirect.ts +101 -0
  154. package/src/route-definition/resolve-handler-use.ts +149 -0
  155. package/src/route-definition.ts +1 -1428
  156. package/src/route-map-builder.ts +217 -123
  157. package/src/route-name.ts +53 -0
  158. package/src/route-types.ts +70 -8
  159. package/src/router/content-negotiation.ts +215 -0
  160. package/src/router/debug-manifest.ts +72 -0
  161. package/src/router/error-handling.ts +9 -9
  162. package/src/router/find-match.ts +160 -0
  163. package/src/router/handler-context.ts +435 -86
  164. package/src/router/intercept-resolution.ts +402 -0
  165. package/src/router/lazy-includes.ts +237 -0
  166. package/src/router/loader-resolution.ts +356 -128
  167. package/src/router/logging.ts +251 -0
  168. package/src/router/manifest.ts +154 -35
  169. package/src/router/match-api.ts +555 -0
  170. package/src/router/match-context.ts +5 -3
  171. package/src/router/match-handlers.ts +440 -0
  172. package/src/router/match-middleware/background-revalidation.ts +108 -93
  173. package/src/router/match-middleware/cache-lookup.ts +459 -10
  174. package/src/router/match-middleware/cache-store.ts +98 -26
  175. package/src/router/match-middleware/intercept-resolution.ts +57 -17
  176. package/src/router/match-middleware/segment-resolution.ts +80 -6
  177. package/src/router/match-pipelines.ts +10 -45
  178. package/src/router/match-result.ts +135 -35
  179. package/src/router/metrics.ts +240 -15
  180. package/src/router/middleware-cookies.ts +55 -0
  181. package/src/router/middleware-types.ts +220 -0
  182. package/src/router/middleware.ts +324 -369
  183. package/src/router/navigation-snapshot.ts +182 -0
  184. package/src/router/pattern-matching.ts +211 -43
  185. package/src/router/prerender-match.ts +502 -0
  186. package/src/router/preview-match.ts +98 -0
  187. package/src/router/request-classification.ts +310 -0
  188. package/src/router/revalidation.ts +137 -38
  189. package/src/router/route-snapshot.ts +245 -0
  190. package/src/router/router-context.ts +41 -21
  191. package/src/router/router-interfaces.ts +484 -0
  192. package/src/router/router-options.ts +618 -0
  193. package/src/router/router-registry.ts +24 -0
  194. package/src/router/segment-resolution/fresh.ts +748 -0
  195. package/src/router/segment-resolution/helpers.ts +268 -0
  196. package/src/router/segment-resolution/loader-cache.ts +199 -0
  197. package/src/router/segment-resolution/revalidation.ts +1379 -0
  198. package/src/router/segment-resolution/static-store.ts +67 -0
  199. package/src/router/segment-resolution.ts +21 -0
  200. package/src/router/segment-wrappers.ts +291 -0
  201. package/src/router/telemetry-otel.ts +299 -0
  202. package/src/router/telemetry.ts +300 -0
  203. package/src/router/timeout.ts +148 -0
  204. package/src/router/trie-matching.ts +239 -0
  205. package/src/router/types.ts +78 -3
  206. package/src/router.ts +740 -4252
  207. package/src/rsc/handler-context.ts +45 -0
  208. package/src/rsc/handler.ts +907 -797
  209. package/src/rsc/helpers.ts +140 -6
  210. package/src/rsc/index.ts +0 -20
  211. package/src/rsc/loader-fetch.ts +229 -0
  212. package/src/rsc/manifest-init.ts +90 -0
  213. package/src/rsc/nonce.ts +14 -0
  214. package/src/rsc/origin-guard.ts +141 -0
  215. package/src/rsc/progressive-enhancement.ts +391 -0
  216. package/src/rsc/response-error.ts +37 -0
  217. package/src/rsc/response-route-handler.ts +347 -0
  218. package/src/rsc/rsc-rendering.ts +246 -0
  219. package/src/rsc/runtime-warnings.ts +42 -0
  220. package/src/rsc/server-action.ts +356 -0
  221. package/src/rsc/ssr-setup.ts +128 -0
  222. package/src/rsc/types.ts +46 -11
  223. package/src/search-params.ts +230 -0
  224. package/src/segment-system.tsx +165 -17
  225. package/src/server/context.ts +315 -58
  226. package/src/server/cookie-store.ts +190 -0
  227. package/src/server/fetchable-loader-store.ts +37 -0
  228. package/src/server/handle-store.ts +113 -15
  229. package/src/server/loader-registry.ts +24 -64
  230. package/src/server/request-context.ts +607 -81
  231. package/src/server.ts +35 -130
  232. package/src/ssr/index.tsx +103 -30
  233. package/src/static-handler.ts +126 -0
  234. package/src/theme/ThemeProvider.tsx +21 -15
  235. package/src/theme/ThemeScript.tsx +5 -5
  236. package/src/theme/constants.ts +5 -2
  237. package/src/theme/index.ts +4 -14
  238. package/src/theme/theme-context.ts +4 -30
  239. package/src/theme/theme-script.ts +21 -18
  240. package/src/types/boundaries.ts +158 -0
  241. package/src/types/cache-types.ts +198 -0
  242. package/src/types/error-types.ts +192 -0
  243. package/src/types/global-namespace.ts +100 -0
  244. package/src/types/handler-context.ts +791 -0
  245. package/src/types/index.ts +88 -0
  246. package/src/types/loader-types.ts +210 -0
  247. package/src/types/route-config.ts +170 -0
  248. package/src/types/route-entry.ts +109 -0
  249. package/src/types/segments.ts +151 -0
  250. package/src/types.ts +1 -1623
  251. package/src/urls/include-helper.ts +197 -0
  252. package/src/urls/index.ts +53 -0
  253. package/src/urls/path-helper-types.ts +346 -0
  254. package/src/urls/path-helper.ts +364 -0
  255. package/src/urls/pattern-types.ts +107 -0
  256. package/src/urls/response-types.ts +116 -0
  257. package/src/urls/type-extraction.ts +372 -0
  258. package/src/urls/urls-function.ts +98 -0
  259. package/src/urls.ts +1 -802
  260. package/src/use-loader.tsx +161 -81
  261. package/src/vite/discovery/bundle-postprocess.ts +181 -0
  262. package/src/vite/discovery/discover-routers.ts +348 -0
  263. package/src/vite/discovery/prerender-collection.ts +439 -0
  264. package/src/vite/discovery/route-types-writer.ts +258 -0
  265. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  266. package/src/vite/discovery/state.ts +117 -0
  267. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  268. package/src/vite/index.ts +15 -1129
  269. package/src/vite/plugin-types.ts +103 -0
  270. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  271. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  272. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  273. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -53
  274. package/src/vite/plugins/expose-id-utils.ts +299 -0
  275. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  276. package/src/vite/plugins/expose-ids/handler-transform.ts +209 -0
  277. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  278. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  279. package/src/vite/plugins/expose-ids/types.ts +45 -0
  280. package/src/vite/plugins/expose-internal-ids.ts +786 -0
  281. package/src/vite/plugins/performance-tracks.ts +88 -0
  282. package/src/vite/plugins/refresh-cmd.ts +127 -0
  283. package/src/vite/plugins/use-cache-transform.ts +323 -0
  284. package/src/vite/plugins/version-injector.ts +83 -0
  285. package/src/vite/plugins/version-plugin.ts +266 -0
  286. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
  287. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  288. package/src/vite/rango.ts +462 -0
  289. package/src/vite/router-discovery.ts +918 -0
  290. package/src/vite/utils/ast-handler-extract.ts +517 -0
  291. package/src/vite/utils/banner.ts +36 -0
  292. package/src/vite/utils/bundle-analysis.ts +137 -0
  293. package/src/vite/utils/manifest-utils.ts +70 -0
  294. package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
  295. package/src/vite/utils/prerender-utils.ts +207 -0
  296. package/src/vite/utils/shared-utils.ts +170 -0
  297. package/CLAUDE.md +0 -43
  298. package/src/browser/lru-cache.ts +0 -69
  299. package/src/browser/request-controller.ts +0 -164
  300. package/src/cache/memory-store.ts +0 -253
  301. package/src/href-context.ts +0 -33
  302. package/src/href.ts +0 -255
  303. package/src/server/route-manifest-cache.ts +0 -173
  304. package/src/vite/expose-handle-id.ts +0 -209
  305. package/src/vite/expose-loader-id.ts +0 -426
  306. package/src/vite/expose-location-state-id.ts +0 -177
  307. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Rango State
3
+ *
4
+ * Manages a localStorage-based state key for HTTP cache invalidation.
5
+ * The key is sent as the `X-Rango-State` header on both prefetch and
6
+ * navigation requests. The server responds with `Vary: X-Rango-State`,
7
+ * so the browser HTTP cache keys responses by (URL, X-Rango-State value).
8
+ *
9
+ * Format: `{buildVersion}:{invalidationTimestamp}`
10
+ * - Build version changes on deploy, busting all cached prefetches.
11
+ * - Timestamp changes on server action invalidation.
12
+ *
13
+ * localStorage is cross-tab and survives page refresh, so:
14
+ * - One tab's prefetch warms the cache for all tabs.
15
+ * - Invalidation in one tab is picked up by other tabs on next fetch.
16
+ */
17
+
18
+ const STORAGE_KEY = "rango-state";
19
+
20
+ // Module-level cache avoids hitting localStorage on every getRangoState() call.
21
+ // Initialized from localStorage on first access or by initRangoState().
22
+ let cachedState: string | null = null;
23
+
24
+ // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
25
+ // to localStorage, keeping cachedState fresh without polling.
26
+ let storageListenerAttached = false;
27
+
28
+ function attachStorageListener(): void {
29
+ if (storageListenerAttached || typeof window === "undefined") return;
30
+ window.addEventListener("storage", (e) => {
31
+ if (e.key !== STORAGE_KEY) return;
32
+ cachedState = e.newValue;
33
+ });
34
+ storageListenerAttached = true;
35
+ }
36
+
37
+ /**
38
+ * Initialize the Rango state key in localStorage.
39
+ * Called once at app startup with the build version from the server.
40
+ * If localStorage already has a key with matching version prefix, keeps it
41
+ * (preserves invalidation state across refresh). Otherwise writes a new key.
42
+ */
43
+ export function initRangoState(version: string): void {
44
+ if (typeof window === "undefined") return;
45
+
46
+ attachStorageListener();
47
+
48
+ try {
49
+ const existing = localStorage.getItem(STORAGE_KEY);
50
+ if (existing) {
51
+ const colonIdx = existing.indexOf(":");
52
+ if (colonIdx > 0) {
53
+ const existingVersion = existing.slice(0, colonIdx);
54
+ if (existingVersion === version) {
55
+ cachedState = existing;
56
+ return;
57
+ }
58
+ }
59
+ }
60
+ // New version or first load
61
+ const newState = `${version}:${Date.now()}`;
62
+ localStorage.setItem(STORAGE_KEY, newState);
63
+ cachedState = newState;
64
+ } catch {
65
+ // localStorage may be unavailable (private browsing in some browsers)
66
+ cachedState = `${version}:${Date.now()}`;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get the current Rango state key value.
72
+ * Used as the `X-Rango-State` header value for prefetch and navigation requests.
73
+ */
74
+ export function getRangoState(): string {
75
+ if (cachedState) return cachedState;
76
+
77
+ if (typeof window === "undefined") return "0:0";
78
+
79
+ try {
80
+ const stored = localStorage.getItem(STORAGE_KEY);
81
+ if (stored) {
82
+ cachedState = stored;
83
+ return stored;
84
+ }
85
+ } catch {
86
+ // Fallback for unavailable localStorage
87
+ }
88
+
89
+ return "0:0";
90
+ }
91
+
92
+ /**
93
+ * Invalidate the Rango state key. Called when server actions mutate data.
94
+ * Updates the timestamp portion while keeping the version prefix.
95
+ * The new value takes effect immediately for all subsequent fetches,
96
+ * causing Vary mismatches with previously cached responses.
97
+ */
98
+ export function invalidateRangoState(): void {
99
+ const current = getRangoState();
100
+ const colonIdx = current.indexOf(":");
101
+ const version = colonIdx > 0 ? current.slice(0, colonIdx) : "0";
102
+ const newState = `${version}:${Date.now()}`;
103
+ cachedState = newState;
104
+
105
+ if (typeof window === "undefined") return;
106
+
107
+ try {
108
+ localStorage.setItem(STORAGE_KEY, newState);
109
+ } catch {
110
+ // Silently handle localStorage errors
111
+ }
112
+ }
@@ -1,69 +1,73 @@
1
1
  "use client";
2
2
 
3
- import React, { forwardRef, useCallback, useContext, useRef, type ForwardRefExoticComponent, type RefAttributes } from "react";
3
+ import React, {
4
+ forwardRef,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ type ForwardRefExoticComponent,
11
+ type RefAttributes,
12
+ } from "react";
4
13
  import { NavigationStoreContext } from "./context.js";
5
14
  import { LinkContext } from "./use-link-status.js";
6
15
  import type { NavigateOptions } from "../types.js";
16
+ import { isHashOnlyNavigation } from "../link-interceptor.js";
7
17
  import {
8
- type LocationStateEntry,
9
18
  isLocationStateEntry,
19
+ type LocationStateEntry,
10
20
  resolveLocationStateEntries,
11
21
  } from "./location-state.js";
12
22
 
13
23
  /**
14
- * State value or getter function for just-in-time state resolution (legacy)
24
+ * State prop type for Link component.
25
+ * - LocationStateEntry[]: Type-safe state entries via createLocationState()
26
+ * - StateOrGetter: Plain state object or click-time getter function
27
+ * - Record<string, unknown>: Plain state object passed to history.pushState
15
28
  */
16
29
  export type StateOrGetter<T = unknown> = T | (() => T);
17
30
 
18
- /**
19
- * State prop type for Link component
20
- * - LocationStateEntry[]: Type-safe state entries (always lazy)
21
- * - StateOrGetter: Legacy format for backwards compatibility
22
- */
23
- export type LinkState = LocationStateEntry[] | StateOrGetter;
24
-
25
- // Track prefetched URLs to avoid duplicate <link> elements
26
- const prefetchedUrls = new Set<string>();
31
+ export type LinkState =
32
+ | LocationStateEntry[]
33
+ | StateOrGetter<Record<string, unknown>>;
27
34
 
28
- /**
29
- * Inject a <link rel="prefetch"> element into the document head
30
- * for the given URL with RSC partial request parameters.
31
- */
32
- function prefetchUrl(url: string, segmentIds: string[]): void {
33
- if (prefetchedUrls.has(url)) return;
34
- prefetchedUrls.add(url);
35
-
36
- // Build RSC partial URL with segment IDs
37
- const targetUrl = new URL(url, window.location.origin);
38
- targetUrl.searchParams.set("_rsc_partial", "true");
39
- if (segmentIds.length > 0) {
40
- targetUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
41
- }
35
+ import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
36
+ import { getAppVersion } from "../app-version.js";
37
+ import {
38
+ observeForPrefetch,
39
+ unobserveForPrefetch,
40
+ } from "../prefetch/observer.js";
42
41
 
43
- // Inject <link rel="prefetch"> into head
44
- const link = document.createElement("link");
45
- link.rel = "prefetch";
46
- link.href = targetUrl.toString();
47
- link.as = "fetch";
48
- document.head.appendChild(link);
49
- }
42
+ // Touch device detection for adaptive strategy.
43
+ // Checked once at module load (Link.tsx is "use client", runs only in browser).
44
+ const isTouchDevice =
45
+ typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
50
46
 
51
47
  /**
52
48
  * Prefetch strategy for the Link component
53
- * - "hover": Prefetch on mouse enter (uses native <link rel="prefetch">)
54
- * - "viewport": Prefetch when link enters viewport (not yet implemented)
55
- * - "hybrid": Hover on desktop, viewport on mobile (not yet implemented)
49
+ * - "hover": Prefetch on mouse enter (direct, no queue)
50
+ * - "viewport": Prefetch when link enters viewport (queued, waits for idle)
51
+ * - "render": Prefetch on component mount regardless of visibility (queued, waits for idle)
52
+ * - "adaptive": Hover on pointer devices, viewport on touch devices
56
53
  * - "none": No prefetching (default)
57
54
  */
58
- export type PrefetchStrategy = "hover" | "viewport" | "hybrid" | "none";
55
+ export type PrefetchStrategy =
56
+ | "hover"
57
+ | "viewport"
58
+ | "render"
59
+ | "adaptive"
60
+ | "none";
59
61
 
60
62
  /**
61
63
  * Link component props
62
64
  */
63
- export interface LinkProps
64
- extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
65
+ export interface LinkProps extends Omit<
66
+ React.AnchorHTMLAttributes<HTMLAnchorElement>,
67
+ "href"
68
+ > {
65
69
  /**
66
- * The URL to navigate to (typically from router.href())
70
+ * The URL to navigate to (typically from router.reverse())
67
71
  */
68
72
  to: string;
69
73
  /**
@@ -78,6 +82,16 @@ export interface LinkProps
78
82
  * Force full document navigation instead of SPA
79
83
  */
80
84
  reloadDocument?: boolean;
85
+ /**
86
+ * Whether to revalidate server data on navigation.
87
+ * Set to `false` to skip the RSC server fetch and only update the URL.
88
+ *
89
+ * Only takes effect when the pathname stays the same (search param / hash changes).
90
+ * If the pathname changes, this option is ignored and a full navigation occurs.
91
+ *
92
+ * @default true
93
+ */
94
+ revalidate?: boolean;
81
95
  /**
82
96
  * Prefetch strategy for the link destination
83
97
  * @default "none"
@@ -90,16 +104,29 @@ export interface LinkProps
90
104
  * @example
91
105
  * ```tsx
92
106
  * // Type-safe state with createLocationState (recommended)
93
- * const ProductState = createLocationState((p: Product) => ({ name: p.name }));
94
- * <Link to="/product" state={[ProductState(product)]}>View</Link>
107
+ * const ProductState = createLocationState<{ name: string; price: number }>();
108
+ * <Link to="/product" state={[ProductState({ name: product.name, price: product.price })]}>
109
+ * View
110
+ * </Link>
111
+ *
112
+ * // Type-safe just-in-time state (getter called at click time, not render time).
113
+ * // Must be in a client component -- getter can't cross the RSC boundary.
114
+ * <Link
115
+ * to="/product"
116
+ * state={[ProductState(() => ({ name: product.name, price: product.price }))]}
117
+ * >
118
+ * View
119
+ * </Link>
95
120
  *
96
121
  * // Multiple typed states
97
- * <Link to="/checkout" state={[ProductState(p), CartState(c)]}>Checkout</Link>
122
+ * <Link to="/checkout" state={[ProductState({ name: p.name, price: p.price }), CartState(c)]}>
123
+ * Checkout
124
+ * </Link>
98
125
  *
99
- * // Legacy: static state
126
+ * // Plain static state
100
127
  * <Link to="/product" state={{ from: "list" }}>View</Link>
101
128
  *
102
- * // Legacy: dynamic state (called at click time)
129
+ * // Plain just-in-time state (called at click time, requires client component)
103
130
  * <Link to="/product" state={() => ({ scrollY: window.scrollY })}>View</Link>
104
131
  * ```
105
132
  */
@@ -134,9 +161,9 @@ function isExternalUrl(href: string): boolean {
134
161
  /**
135
162
  * Type-safe Link component for SPA navigation
136
163
  *
137
- * Works with router.href() for type-safe URLs:
164
+ * Works with router.reverse() for type-safe URLs:
138
165
  * ```tsx
139
- * <Link to={router.href("shop.products.detail", { slug: "my-product" })}>
166
+ * <Link to={router.reverse("shop.products.detail", { slug: "my-product" })}>
140
167
  * View Product
141
168
  * </Link>
142
169
  * ```
@@ -147,23 +174,55 @@ function isExternalUrl(href: string): boolean {
147
174
  * <Link to="https://example.com">External</Link>
148
175
  * ```
149
176
  */
150
- export const Link: ForwardRefExoticComponent<LinkProps & RefAttributes<HTMLAnchorElement>> = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
177
+ export const Link: ForwardRefExoticComponent<
178
+ LinkProps & RefAttributes<HTMLAnchorElement>
179
+ > = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
151
180
  {
152
181
  to,
153
182
  replace = false,
154
183
  scroll = true,
155
184
  reloadDocument = false,
185
+ revalidate,
156
186
  prefetch = "none",
157
187
  state,
158
188
  children,
159
189
  onClick,
160
190
  ...props
161
191
  },
162
- ref
192
+ ref,
163
193
  ) {
164
194
  const ctx = useContext(NavigationStoreContext);
165
195
  const isExternal = isExternalUrl(to);
166
196
 
197
+ // Auto-prefix with basename for app-local paths.
198
+ // Skip if external, already prefixed, or not a root-relative path.
199
+ const resolvedTo = useMemo(() => {
200
+ if (isExternal) return to;
201
+ const bn = ctx?.basename;
202
+ if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
203
+ return to;
204
+ return to === "/" ? bn : bn + to;
205
+ }, [to, isExternal, ctx?.basename]);
206
+
207
+ // Resolve adaptive: viewport on touch devices, hover on pointer devices
208
+ const resolvedStrategy =
209
+ prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
210
+
211
+ // Internal ref for viewport observation; merge with forwarded ref
212
+ const internalRef = useRef<HTMLAnchorElement | null>(null);
213
+ const setRef = useCallback(
214
+ (node: HTMLAnchorElement | null) => {
215
+ internalRef.current = node;
216
+ if (typeof ref === "function") {
217
+ ref(node);
218
+ } else if (ref) {
219
+ (ref as React.MutableRefObject<HTMLAnchorElement | null>).current =
220
+ node;
221
+ }
222
+ },
223
+ [ref],
224
+ );
225
+
167
226
  // Use ref to always get the latest state/getter without adding to useCallback deps
168
227
  // This enables just-in-time state resolution without causing re-renders
169
228
  const stateRef = useRef(state);
@@ -194,55 +253,152 @@ export const Link: ForwardRefExoticComponent<LinkProps & RefAttributes<HTMLAncho
194
253
  const target = (e.currentTarget as HTMLAnchorElement).target;
195
254
  if (target && target !== "_self") return;
196
255
 
256
+ // Hash-only navigation: let the browser handle anchor scrolling natively.
257
+ if (isHashOnlyNavigation(e.currentTarget as HTMLAnchorElement)) {
258
+ return;
259
+ }
260
+
261
+ // No navigation context (outside provider): fall back to native navigation.
262
+ if (!ctx?.navigate) {
263
+ return;
264
+ }
265
+
197
266
  // Prevent default and use SPA navigation
198
267
  e.preventDefault();
199
268
  // Stop propagation to prevent link-interceptor from also handling this
200
269
  e.stopPropagation();
201
270
 
202
- if (ctx?.navigate) {
203
- // Resolve state just-in-time based on format
204
- let resolvedState: unknown;
205
- const currentState = stateRef.current;
206
-
207
- if (Array.isArray(currentState) && currentState.length > 0 && isLocationStateEntry(currentState[0])) {
208
- // Type-safe LocationStateEntry[] - resolve each entry into keyed object
209
- resolvedState = resolveLocationStateEntries(currentState as LocationStateEntry[]);
210
- } else if (typeof currentState === "function") {
211
- // Legacy getter function
212
- resolvedState = currentState();
213
- } else {
214
- // Legacy static value
215
- resolvedState = currentState;
216
- }
271
+ const currentState = stateRef.current;
272
+ let resolvedState: unknown;
217
273
 
218
- ctx.navigate(to, { replace, scroll, state: resolvedState });
274
+ if (
275
+ Array.isArray(currentState) &&
276
+ currentState.length > 0 &&
277
+ isLocationStateEntry(currentState[0])
278
+ ) {
279
+ resolvedState = resolveLocationStateEntries(
280
+ currentState as LocationStateEntry[],
281
+ );
282
+ } else if (typeof currentState === "function") {
283
+ resolvedState = currentState();
284
+ } else if (currentState != null) {
285
+ resolvedState = currentState;
219
286
  }
287
+
288
+ ctx.navigate(resolvedTo, {
289
+ replace,
290
+ scroll,
291
+ state: resolvedState,
292
+ revalidate,
293
+ });
220
294
  },
221
- [to, isExternal, reloadDocument, replace, scroll, ctx, onClick]
295
+ [
296
+ resolvedTo,
297
+ isExternal,
298
+ reloadDocument,
299
+ replace,
300
+ scroll,
301
+ revalidate,
302
+ ctx,
303
+ onClick,
304
+ ],
222
305
  );
223
306
 
224
307
  const handleMouseEnter = useCallback(() => {
225
- if (prefetch === "hover" && !isExternal && ctx?.store) {
308
+ if (
309
+ (resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
310
+ !isExternal &&
311
+ ctx?.store
312
+ ) {
313
+ // For "hover", this is the primary prefetch trigger.
314
+ // For "viewport", this upgrades/prioritizes a potentially queued
315
+ // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
316
+ // deduplicates if the viewport prefetch already completed.
317
+ const segmentState = ctx.store.getSegmentState();
318
+ prefetchDirect(
319
+ resolvedTo,
320
+ segmentState.currentSegmentIds,
321
+ getAppVersion(),
322
+ ctx.store.getRouterId?.(),
323
+ );
324
+ }
325
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx]);
326
+
327
+ // Viewport/render prefetch: waits for idle before starting,
328
+ // uses concurrency-limited queue to avoid flooding.
329
+ useEffect(() => {
330
+ if (isExternal || !ctx?.store) return;
331
+ const isViewport = resolvedStrategy === "viewport";
332
+ const isRender = resolvedStrategy === "render";
333
+ if (!isViewport && !isRender) return;
334
+
335
+ let cancelled = false;
336
+ let unsubIdle: (() => void) | undefined;
337
+ let observedElement: Element | null = null;
338
+
339
+ const triggerPrefetch = () => {
340
+ if (cancelled) return;
226
341
  const segmentState = ctx.store.getSegmentState();
227
- prefetchUrl(to, segmentState.currentSegmentIds);
342
+ prefetchQueued(
343
+ resolvedTo,
344
+ segmentState.currentSegmentIds,
345
+ getAppVersion(),
346
+ ctx.store.getRouterId?.(),
347
+ );
348
+ };
349
+
350
+ // Schedule prefetch only when the app is idle (no navigation/streaming).
351
+ // This avoids competing with hydration and active navigation fetches.
352
+ const scheduleWhenIdle = (callback: () => void) => {
353
+ const state = ctx.eventController.getState();
354
+ if (state.state === "idle" && !state.isStreaming) {
355
+ callback();
356
+ return;
357
+ }
358
+ const unsub = ctx.eventController.subscribe(() => {
359
+ const s = ctx.eventController.getState();
360
+ if (s.state === "idle" && !s.isStreaming) {
361
+ unsub();
362
+ callback();
363
+ }
364
+ });
365
+ unsubIdle = unsub;
366
+ };
367
+
368
+ if (isRender) {
369
+ scheduleWhenIdle(triggerPrefetch);
370
+ } else if (isViewport) {
371
+ const element = internalRef.current;
372
+ if (!element) return;
373
+ observedElement = element;
374
+ observeForPrefetch(element, () => {
375
+ scheduleWhenIdle(triggerPrefetch);
376
+ });
228
377
  }
229
- }, [prefetch, to, isExternal, ctx]);
378
+
379
+ return () => {
380
+ cancelled = true;
381
+ unsubIdle?.();
382
+ if (isViewport && observedElement) {
383
+ unobserveForPrefetch(observedElement);
384
+ }
385
+ };
386
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx]);
230
387
 
231
388
  return (
232
389
  <a
233
- ref={ref}
234
- href={to}
390
+ ref={setRef}
391
+ href={resolvedTo}
235
392
  onClick={handleClick}
236
393
  onMouseEnter={handleMouseEnter}
237
394
  data-link-component
238
395
  data-external={isExternal ? "" : undefined}
239
396
  data-scroll={scroll === false ? "false" : undefined}
240
397
  data-replace={replace ? "true" : undefined}
398
+ data-revalidate={revalidate === false ? "false" : undefined}
241
399
  {...props}
242
400
  >
243
- <LinkContext.Provider value={to}>
244
- {children}
245
- </LinkContext.Provider>
401
+ <LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
246
402
  </a>
247
403
  );
248
404
  });