@rangojs/router 0.0.0-experimental.122 → 0.0.0-experimental.125

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 (260) hide show
  1. package/dist/bin/rango.js +10 -6
  2. package/dist/testing/vitest.js +82 -0
  3. package/dist/vite/index.js +55 -48
  4. package/package.json +61 -21
  5. package/skills/caching/SKILL.md +2 -1
  6. package/skills/hooks/SKILL.md +40 -29
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +3 -1
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +12 -0
  15. package/skills/route/SKILL.md +10 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/src/__internal.ts +0 -65
  32. package/src/browser/action-coordinator.ts +1 -1
  33. package/src/browser/action-fence.ts +47 -0
  34. package/src/browser/cookie-name.ts +140 -0
  35. package/src/browser/event-controller.ts +1 -83
  36. package/src/browser/invalidate-client-cache.ts +52 -0
  37. package/src/browser/navigation-bridge.ts +14 -1
  38. package/src/browser/navigation-client.ts +14 -1
  39. package/src/browser/navigation-store-handle.ts +38 -0
  40. package/src/browser/navigation-store.ts +26 -51
  41. package/src/browser/navigation-transaction.ts +0 -32
  42. package/src/browser/partial-update.ts +1 -83
  43. package/src/browser/prefetch/cache.ts +6 -45
  44. package/src/browser/prefetch/fetch.ts +7 -0
  45. package/src/browser/prefetch/queue.ts +6 -3
  46. package/src/browser/rango-state.ts +157 -99
  47. package/src/browser/react/Link.tsx +0 -2
  48. package/src/browser/react/NavigationProvider.tsx +2 -1
  49. package/src/browser/react/ScrollRestoration.tsx +10 -6
  50. package/src/browser/react/filter-segment-order.ts +0 -2
  51. package/src/browser/react/index.ts +0 -51
  52. package/src/browser/react/location-state-shared.ts +0 -13
  53. package/src/browser/react/location-state.ts +0 -1
  54. package/src/browser/react/use-action.ts +6 -15
  55. package/src/browser/react/use-handle.ts +0 -5
  56. package/src/browser/react/use-link-status.ts +0 -4
  57. package/src/browser/react/use-navigation.ts +0 -3
  58. package/src/browser/react/use-params.ts +0 -2
  59. package/src/browser/react/use-search-params.ts +0 -5
  60. package/src/browser/react/use-segments.ts +0 -13
  61. package/src/browser/rsc-router.tsx +12 -4
  62. package/src/browser/server-action-bridge.ts +77 -15
  63. package/src/browser/types.ts +7 -2
  64. package/src/browser/validate-redirect-origin.ts +4 -5
  65. package/src/build/route-trie.ts +3 -0
  66. package/src/build/route-types/param-extraction.ts +6 -3
  67. package/src/build/route-types/router-processing.ts +0 -8
  68. package/src/cache/cache-policy.ts +0 -54
  69. package/src/cache/cache-runtime.ts +27 -24
  70. package/src/cache/cache-scope.ts +0 -27
  71. package/src/cache/cache-tag.ts +0 -37
  72. package/src/cache/cf/cf-cache-store.ts +94 -46
  73. package/src/cache/cf/index.ts +0 -24
  74. package/src/cache/document-cache.ts +11 -36
  75. package/src/cache/handle-snapshot.ts +0 -40
  76. package/src/cache/index.ts +0 -27
  77. package/src/cache/memory-segment-store.ts +2 -48
  78. package/src/cache/profile-registry.ts +7 -3
  79. package/src/cache/read-through-swr.ts +41 -11
  80. package/src/cache/segment-codec.ts +0 -16
  81. package/src/cache/types.ts +0 -98
  82. package/src/client.rsc.tsx +1 -22
  83. package/src/client.tsx +14 -38
  84. package/src/component-utils.ts +19 -0
  85. package/src/deps/ssr.ts +0 -1
  86. package/src/handle.ts +28 -18
  87. package/src/handles/MetaTags.tsx +0 -14
  88. package/src/handles/meta.ts +0 -39
  89. package/src/host/cookie-handler.ts +0 -36
  90. package/src/host/errors.ts +0 -24
  91. package/src/host/index.ts +6 -0
  92. package/src/host/pattern-matcher.ts +7 -50
  93. package/src/host/router.ts +1 -65
  94. package/src/host/testing.ts +40 -27
  95. package/src/host/types.ts +6 -2
  96. package/src/href-client.ts +0 -4
  97. package/src/index.rsc.ts +42 -3
  98. package/src/index.ts +31 -1
  99. package/src/internal-debug.ts +2 -4
  100. package/src/loader.rsc.ts +19 -9
  101. package/src/loader.ts +12 -4
  102. package/src/network-error-thrower.tsx +1 -6
  103. package/src/outlet-provider.tsx +1 -5
  104. package/src/prerender/param-hash.ts +10 -11
  105. package/src/prerender/store.ts +23 -30
  106. package/src/prerender.ts +58 -3
  107. package/src/root-error-boundary.tsx +1 -19
  108. package/src/route-content-wrapper.tsx +1 -44
  109. package/src/route-definition/dsl-helpers.ts +7 -19
  110. package/src/route-definition/helpers-types.ts +3 -3
  111. package/src/route-definition/redirect.ts +11 -1
  112. package/src/route-map-builder.ts +0 -16
  113. package/src/router/basename.ts +14 -0
  114. package/src/router/content-negotiation.ts +0 -13
  115. package/src/router/error-handling.ts +12 -16
  116. package/src/router/find-match.ts +4 -30
  117. package/src/router/intercept-resolution.ts +10 -1
  118. package/src/router/lazy-includes.ts +1 -57
  119. package/src/router/loader-resolution.ts +3 -2
  120. package/src/router/logging.ts +0 -6
  121. package/src/router/manifest.ts +1 -25
  122. package/src/router/match-api.ts +0 -20
  123. package/src/router/match-context.ts +0 -22
  124. package/src/router/match-handlers.ts +57 -58
  125. package/src/router/match-middleware/background-revalidation.ts +0 -7
  126. package/src/router/match-middleware/cache-lookup.ts +1 -54
  127. package/src/router/match-middleware/cache-store.ts +0 -31
  128. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  129. package/src/router/match-middleware/segment-resolution.ts +0 -21
  130. package/src/router/match-pipelines.ts +1 -42
  131. package/src/router/match-result.ts +1 -52
  132. package/src/router/metrics.ts +0 -34
  133. package/src/router/middleware-cookies.ts +0 -13
  134. package/src/router/middleware-types.ts +0 -115
  135. package/src/router/middleware.ts +7 -30
  136. package/src/router/navigation-snapshot.ts +0 -51
  137. package/src/router/params-util.ts +23 -0
  138. package/src/router/pattern-matching.ts +1 -33
  139. package/src/router/prerender-match.ts +33 -45
  140. package/src/router/request-classification.ts +1 -38
  141. package/src/router/revalidation.ts +5 -58
  142. package/src/router/router-context.ts +0 -26
  143. package/src/router/router-interfaces.ts +7 -0
  144. package/src/router/router-options.ts +30 -0
  145. package/src/router/segment-resolution/fresh.ts +25 -57
  146. package/src/router/segment-resolution/helpers.ts +34 -0
  147. package/src/router/segment-resolution/loader-cache.ts +10 -13
  148. package/src/router/segment-resolution/revalidation.ts +5 -42
  149. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  150. package/src/router/segment-resolution.ts +4 -1
  151. package/src/router/state-cookie-name.ts +33 -0
  152. package/src/router/telemetry-otel.ts +0 -20
  153. package/src/router/telemetry.ts +96 -19
  154. package/src/router/timeout.ts +0 -20
  155. package/src/router/trie-matching.ts +63 -40
  156. package/src/router/types.ts +1 -63
  157. package/src/router/url-params.ts +0 -5
  158. package/src/router.ts +40 -9
  159. package/src/rsc/handler.ts +14 -2
  160. package/src/rsc/helpers.ts +34 -0
  161. package/src/rsc/origin-guard.ts +0 -12
  162. package/src/rsc/progressive-enhancement.ts +4 -1
  163. package/src/rsc/rsc-rendering.ts +4 -7
  164. package/src/rsc/runtime-warnings.ts +14 -0
  165. package/src/rsc/server-action.ts +30 -28
  166. package/src/rsc/types.ts +2 -1
  167. package/src/runtime-env.ts +18 -0
  168. package/src/search-params.ts +0 -16
  169. package/src/segment-loader-promise.ts +14 -2
  170. package/src/segment-system.tsx +79 -88
  171. package/src/server/cookie-store.ts +52 -1
  172. package/src/server/handle-store.ts +7 -24
  173. package/src/server/loader-registry.ts +5 -24
  174. package/src/server/request-context.ts +74 -77
  175. package/src/ssr/index.tsx +14 -14
  176. package/src/static-handler.ts +10 -13
  177. package/src/testing/cache-status.ts +119 -0
  178. package/src/testing/collect-handle.ts +40 -0
  179. package/src/testing/dispatch.ts +581 -0
  180. package/src/testing/dom.entry.ts +22 -0
  181. package/src/testing/e2e/fixture.ts +188 -0
  182. package/src/testing/e2e/index.ts +127 -0
  183. package/src/testing/e2e/matchers.ts +35 -0
  184. package/src/testing/e2e/page-helpers.ts +272 -0
  185. package/src/testing/e2e/parity.ts +387 -0
  186. package/src/testing/e2e/server.ts +195 -0
  187. package/src/testing/flight-matchers.ts +97 -0
  188. package/src/testing/flight-normalize.ts +11 -0
  189. package/src/testing/flight-runtime.d.ts +57 -0
  190. package/src/testing/flight-tree.ts +682 -0
  191. package/src/testing/flight.entry.ts +52 -0
  192. package/src/testing/flight.ts +186 -0
  193. package/src/testing/generated-routes.ts +183 -0
  194. package/src/testing/index.ts +98 -0
  195. package/src/testing/internal/context.ts +348 -0
  196. package/src/testing/internal/flight-client-globals.ts +30 -0
  197. package/src/testing/internal/seed-vars.ts +54 -0
  198. package/src/testing/render-handler.ts +311 -0
  199. package/src/testing/render-route.tsx +504 -0
  200. package/src/testing/run-loader.ts +378 -0
  201. package/src/testing/run-middleware.ts +205 -0
  202. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  203. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  204. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  205. package/src/testing/vitest-stubs/version.ts +5 -0
  206. package/src/testing/vitest.ts +305 -0
  207. package/src/theme/ThemeProvider.tsx +0 -52
  208. package/src/theme/ThemeScript.tsx +0 -6
  209. package/src/theme/constants.ts +0 -12
  210. package/src/theme/index.ts +0 -7
  211. package/src/theme/theme-context.ts +1 -5
  212. package/src/theme/theme-script.ts +0 -14
  213. package/src/theme/use-theme.ts +0 -3
  214. package/src/types/boundaries.ts +0 -35
  215. package/src/types/error-types.ts +25 -89
  216. package/src/types/global-namespace.ts +15 -15
  217. package/src/types/handler-context.ts +16 -13
  218. package/src/types/index.ts +0 -10
  219. package/src/types/request-scope.ts +0 -19
  220. package/src/types/route-config.ts +6 -50
  221. package/src/types/route-entry.ts +0 -6
  222. package/src/types/segments.ts +0 -13
  223. package/src/urls/include-helper.ts +0 -4
  224. package/src/urls/index.ts +0 -6
  225. package/src/urls/path-helper-types.ts +2 -2
  226. package/src/urls/path-helper.ts +0 -54
  227. package/src/urls/urls-function.ts +0 -13
  228. package/src/use-loader.tsx +0 -186
  229. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  230. package/src/vite/discovery/discover-routers.ts +6 -7
  231. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  232. package/src/vite/plugin-types.ts +3 -1
  233. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  234. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  235. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  236. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  237. package/src/vite/plugins/expose-action-id.ts +2 -73
  238. package/src/vite/plugins/expose-id-utils.ts +0 -55
  239. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  240. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  241. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  242. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  243. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  244. package/src/vite/plugins/performance-tracks.ts +0 -3
  245. package/src/vite/plugins/use-cache-transform.ts +0 -36
  246. package/src/vite/plugins/version-injector.ts +0 -20
  247. package/src/vite/plugins/version-plugin.ts +1 -49
  248. package/src/vite/plugins/virtual-entries.ts +0 -15
  249. package/src/vite/rango.ts +1 -108
  250. package/src/vite/router-discovery.ts +2 -1
  251. package/src/vite/utils/ast-handler-extract.ts +0 -16
  252. package/src/vite/utils/bundle-analysis.ts +6 -13
  253. package/src/vite/utils/client-chunks.ts +0 -6
  254. package/src/vite/utils/forward-user-plugins.ts +0 -22
  255. package/src/vite/utils/manifest-utils.ts +0 -4
  256. package/src/vite/utils/package-resolution.ts +1 -73
  257. package/src/vite/utils/prerender-utils.ts +0 -35
  258. package/src/vite/utils/shared-utils.ts +3 -35
  259. package/src/browser/react/use-client-cache.ts +0 -58
  260. package/src/browser/shallow.ts +0 -40
@@ -1,136 +1,194 @@
1
1
  /**
2
2
  * Rango State
3
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).
4
+ * Manages a session-cookie-based state value for HTTP cache invalidation. The
5
+ * value is sent as the `X-Rango-State` header on prefetch and navigation
6
+ * requests; the server responds with `Vary: X-Rango-State`, so the browser HTTP
7
+ * cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
9
  * Value format: `{buildVersion}:{invalidationTimestamp}`
10
- * - Build version changes on deploy, busting all cached prefetches.
11
- * - Timestamp changes on server action invalidation.
10
+ * - Build version changes on deploy, busting all cached prefetches at boot.
11
+ * - Timestamp rotates on invalidation (server action, invalidateClientCache).
12
12
  *
13
- * Storage key is namespaced per routerId (`rango-state:{routerId}`) so
14
- * tabs in different apps on the same origin do not collide. Two tabs in
15
- * the same app share a key one tab's invalidation is picked up by the
16
- * other via the `storage` event. The key is bound once at document init; a
17
- * cross-app navigation is a full document load (X-RSC-Reload), so the target
18
- * app's document binds its own key on load (tabs in the old app keep theirs).
13
+ * Storage is a session cookie named by the server-resolved name passed to
14
+ * initRangoState (`{prefix}_{routerId}`, default prefix `rango-state`). The
15
+ * cookie jar is shared across tabs, so a per-request read IS the cross-tab
16
+ * value sync no `storage` event is needed. An in-memory mirror is a
17
+ * write-through copy that is authoritative only when the cookie is unreadable
18
+ * (e.g. a sandboxed frame, or site data blocked wholesale): the failure
19
+ * direction is always toward freshness.
19
20
  *
20
- * If no routerId is supplied, falls back to a single legacy key for
21
- * backward compatibility (single-app deployments unaffected).
21
+ * Precedence is load-bearing: when `document.cookie` is readable, the
22
+ * per-request read wins; the mirror is a fallback, never a cache of the read.
23
+ * Caching the read across requests would reintroduce the staleness this
24
+ * mechanism removes.
22
25
  */
23
26
 
24
- const LEGACY_STORAGE_KEY = "rango-state";
27
+ import {
28
+ DEFAULT_STATE_COOKIE_PREFIX,
29
+ decodeStateValue,
30
+ getRawCookieValue,
31
+ mintStateValue,
32
+ serializeStateCookie,
33
+ } from "./cookie-name.js";
25
34
 
26
- function buildStorageKey(routerId: string | undefined): string {
27
- return routerId ? `${LEGACY_STORAGE_KEY}:${routerId}` : LEGACY_STORAGE_KEY;
28
- }
35
+ let cookieName: string = DEFAULT_STATE_COOKIE_PREFIX;
29
36
 
30
- // Module-level cache avoids hitting localStorage on every getRangoState() call.
31
- // Initialized from localStorage on first access or by initRangoState().
32
- let cachedState: string | null = null;
33
-
34
- // The localStorage key this tab is currently bound to. Bound on
35
- // initRangoState (document boot). The storage listener filters cross-tab
36
- // events by this key so events from tabs in a different app are ignored.
37
- let currentStorageKey: string = LEGACY_STORAGE_KEY;
38
-
39
- // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
40
- // to localStorage, keeping cachedState fresh without polling.
41
- let storageListenerAttached = false;
42
-
43
- function attachStorageListener(): void {
44
- if (storageListenerAttached || typeof window === "undefined") return;
45
- window.addEventListener("storage", (e) => {
46
- // Only react to events for this tab's current app namespace. Events
47
- // under other routerId-scoped keys belong to other apps and must not
48
- // clobber this tab's state.
49
- if (e.key !== currentStorageKey) return;
50
- cachedState = e.newValue;
51
- });
52
- storageListenerAttached = true;
53
- }
37
+ let currentVersion = "0";
38
+
39
+ let mirror: string | null = null;
40
+ let cookieBacked = false;
41
+
42
+ let externalRotationObserver: ((value: string) => void) | null = null;
54
43
 
55
44
  /**
56
- * Initialize the Rango state key in localStorage.
57
- * Called once at app startup with the build version from the server.
58
- * The routerId scopes the storage key to this app; in multi-app setups
59
- * each app owns its own `rango-state:{routerId}` key and cannot observe
60
- * invalidations from sibling apps on the same origin.
61
- *
62
- * If localStorage already has a matching-version entry under the key,
63
- * keeps it (preserves invalidation state across refresh). Otherwise
64
- * writes a new value.
45
+ * Register the observer invoked when a read detects an EXTERNAL rotation (a
46
+ * sibling tab, a server `Set-Cookie`, or a cookie clear). Self-rotations
47
+ * (invalidateRangoState) update the mirror synchronously and never fire it.
65
48
  */
66
- export function initRangoState(version: string, routerId?: string): void {
67
- currentStorageKey = buildStorageKey(routerId);
68
- if (typeof window === "undefined") return;
49
+ export function setRangoStateObserver(
50
+ observer: ((value: string) => void) | null,
51
+ ): void {
52
+ externalRotationObserver = observer;
53
+ }
54
+
55
+ function notifyExternalRotation(value: string): void {
56
+ externalRotationObserver?.(value);
57
+ }
69
58
 
70
- attachStorageListener();
59
+ interface CookieRead {
60
+ /** False when there is no document or the read threw (sandboxed frame). */
61
+ readable: boolean;
62
+ /** The cookie value, or null when readable but absent. */
63
+ value: string | null;
64
+ }
71
65
 
66
+ function readCookie(name: string): CookieRead {
67
+ if (typeof document === "undefined") return { readable: false, value: null };
68
+ let raw: string;
72
69
  try {
73
- const existing = localStorage.getItem(currentStorageKey);
74
- if (existing) {
75
- const colonIdx = existing.indexOf(":");
76
- if (colonIdx > 0) {
77
- const existingVersion = existing.slice(0, colonIdx);
78
- if (existingVersion === version) {
79
- cachedState = existing;
80
- return;
81
- }
82
- }
83
- }
84
- // New version or first load
85
- const newState = `${version}:${Date.now()}`;
86
- localStorage.setItem(currentStorageKey, newState);
87
- cachedState = newState;
70
+ raw = document.cookie;
88
71
  } catch {
89
- // localStorage may be unavailable (private browsing in some browsers)
90
- cachedState = `${version}:${Date.now()}`;
72
+ return { readable: false, value: null };
91
73
  }
74
+ return { readable: true, value: getRawCookieValue(raw, name) };
75
+ }
76
+
77
+ function writeCookie(name: string, value: string): void {
78
+ if (typeof document === "undefined") return;
79
+ const secure =
80
+ typeof location !== "undefined" && location.protocol === "https:";
81
+ try {
82
+ document.cookie = serializeStateCookie(name, value, secure);
83
+ } catch {}
84
+ }
85
+
86
+ function mintValue(): string {
87
+ return mintStateValue(currentVersion, mirror);
92
88
  }
93
89
 
94
90
  /**
95
- * Get the current Rango state key value.
96
- * Used as the `X-Rango-State` header value for prefetch and navigation requests.
91
+ * Initialize the Rango state cookie at app startup. `version` is the build
92
+ * version; `stateCookieName` is the server-resolved cookie name from payload
93
+ * metadata (falls back to the bare default prefix when a payload arrives
94
+ * without it). Keeps an existing matching-version cookie (preserves the cache
95
+ * key across reloads); mints fresh on a version change or a missing cookie.
96
+ */
97
+ export function initRangoState(
98
+ version: string,
99
+ stateCookieName?: string,
100
+ ): void {
101
+ currentVersion = version;
102
+ cookieName = stateCookieName || DEFAULT_STATE_COOKIE_PREFIX;
103
+ cleanupLegacyStorage();
104
+
105
+ const read = readCookie(cookieName);
106
+ if (!read.readable) {
107
+ // Cookies unreadable: the mirror is the source of truth for this session.
108
+ mirror = mintValue();
109
+ cookieBacked = false;
110
+ return;
111
+ }
112
+ if (read.value !== null) {
113
+ const decoded = decodeStateValue(read.value);
114
+ if (decoded && decoded.version === version) {
115
+ // Keep: a matching-version cookie survives the reload warm.
116
+ mirror = read.value;
117
+ cookieBacked = true;
118
+ return;
119
+ }
120
+ }
121
+ // Absent, malformed, or a version change (deploy): mint fresh and write.
122
+ mirror = mintValue();
123
+ cookieBacked = false;
124
+ writeCookie(cookieName, mirror);
125
+ }
126
+
127
+ /**
128
+ * Get the current Rango state value, used as the `X-Rango-State` header on
129
+ * prefetch and navigation requests. Reads the cookie every call (the read is
130
+ * the cross-tab sync channel) and reconciles the mirror.
97
131
  */
98
132
  export function getRangoState(): string {
99
- if (cachedState) return cachedState;
133
+ const read = readCookie(cookieName);
100
134
 
101
- if (typeof window === "undefined") return "0:0";
135
+ if (!read.readable) {
136
+ // Mirror authoritative when the jar is unreadable.
137
+ return mirror ?? "0:0";
138
+ }
102
139
 
103
- try {
104
- const stored = localStorage.getItem(currentStorageKey);
105
- if (stored) {
106
- cachedState = stored;
107
- return stored;
140
+ if (read.value !== null) {
141
+ if (read.value !== mirror) {
142
+ // External rotation (sibling tab / server Set-Cookie): adopt it. The
143
+ // mirror update makes this idempotent across a burst of reads.
144
+ mirror = read.value;
145
+ cookieBacked = true;
146
+ notifyExternalRotation(read.value);
147
+ } else {
148
+ cookieBacked = true;
108
149
  }
109
- } catch {
110
- // Fallback for unavailable localStorage
150
+ return read.value;
111
151
  }
112
152
 
113
- return "0:0";
153
+ // Readable but absent.
154
+ if (cookieBacked) {
155
+ // present -> absent: an external clear. Mint fresh, write back, and notify
156
+ // once (cookieBacked flips to false so we don't re-fire on the next read).
157
+ mirror = mintValue();
158
+ cookieBacked = false;
159
+ writeCookie(cookieName, mirror);
160
+ notifyExternalRotation(mirror);
161
+ } else if (mirror === null) {
162
+ // First access with no cookie yet (pre-boot): mint silently — there is
163
+ // nothing to invalidate.
164
+ mirror = mintValue();
165
+ writeCookie(cookieName, mirror);
166
+ }
167
+ return mirror;
114
168
  }
115
169
 
116
170
  /**
117
- * Invalidate the Rango state key. Called when server actions mutate data.
118
- * Updates the timestamp portion while keeping the version prefix.
119
- * The new value takes effect immediately for all subsequent fetches,
120
- * causing Vary mismatches with previously cached responses.
171
+ * Invalidate the Rango state (self-rotation). Called when the client clears its
172
+ * prefetch caches (e.g. via the server-action bridge). Rotates the timestamp,
173
+ * keeps the version, writes the cookie, and updates the mirror synchronously so
174
+ * the external-rotation observer is NOT triggered by our own write.
121
175
  */
122
176
  export function invalidateRangoState(): void {
123
- const current = getRangoState();
124
- const colonIdx = current.indexOf(":");
125
- const version = colonIdx > 0 ? current.slice(0, colonIdx) : "0";
126
- const newState = `${version}:${Date.now()}`;
127
- cachedState = newState;
128
-
129
- if (typeof window === "undefined") return;
177
+ mirror = mintValue();
178
+ cookieBacked = false;
179
+ writeCookie(cookieName, mirror);
180
+ }
130
181
 
182
+ function cleanupLegacyStorage(): void {
183
+ if (typeof localStorage === "undefined") return;
131
184
  try {
132
- localStorage.setItem(currentStorageKey, newState);
133
- } catch {
134
- // Silently handle localStorage errors
135
- }
185
+ const toRemove: string[] = [];
186
+ for (let i = 0; i < localStorage.length; i++) {
187
+ const key = localStorage.key(i);
188
+ if (key === "rango-state" || (key && key.startsWith("rango-state:"))) {
189
+ toRemove.push(key);
190
+ }
191
+ }
192
+ for (const key of toRemove) localStorage.removeItem(key);
193
+ } catch {}
136
194
  }
@@ -39,8 +39,6 @@ import {
39
39
  unobserveForPrefetch,
40
40
  } from "../prefetch/observer.js";
41
41
 
42
- // Touch device detection for adaptive strategy.
43
- // Checked once at module load (Link.tsx is "use client", runs only in browser).
44
42
  const isTouchDevice =
45
43
  typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
46
44
 
@@ -29,6 +29,7 @@ import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
31
  import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
+ import { debugLog } from "../logging.js";
32
33
 
33
34
  /**
34
35
  * Process handles from an async generator, updating the event controller
@@ -70,7 +71,7 @@ async function processHandles(
70
71
  // This prevents handle data from cancelled navigations polluting
71
72
  // the current route's breadcrumbs (e.g., quick popstate after clicking a link).
72
73
  if (historyKey !== store.getHistoryKey()) {
73
- console.log(
74
+ debugLog(
74
75
  "[NavigationProvider] Stopping handle processing - user navigated away",
75
76
  );
76
77
  return;
@@ -14,17 +14,21 @@ export interface ScrollRestorationProps {
14
14
  * Return location.pathname to restore scroll based on path
15
15
  * (useful for keeping scroll position on the same page).
16
16
  *
17
+ * Provide a stable reference: a module-level function or one wrapped in
18
+ * useCallback. The init effect re-runs when getKey's identity changes, and
19
+ * teardown clears in-memory scroll positions — a fresh inline arrow on every
20
+ * parent render would discard unpersisted positions mid-session.
21
+ *
17
22
  * @example
18
23
  * ```tsx
24
+ * // Stable module-level getKey (recommended)
25
+ * const byPathname = (location) => location.pathname;
26
+ *
19
27
  * // Restore based on pathname (same URL = same scroll)
20
- * <ScrollRestoration
21
- * getKey={(location) => location.pathname}
22
- * />
28
+ * <ScrollRestoration getKey={byPathname} />
23
29
  *
24
30
  * // Restore based on unique history entry (default)
25
- * <ScrollRestoration
26
- * getKey={(location) => location.key}
27
- * />
31
+ * // <ScrollRestoration /> — omit getKey to use location.key
28
32
  * ```
29
33
  */
30
34
  getKey?: (location: {
@@ -46,8 +46,6 @@ export function filterSegmentOrder(matched: string[]): string[] {
46
46
  const slots = slotsByParent.get(id);
47
47
  if (slots) result.push(...slots);
48
48
  }
49
- // Defensive: any slot whose parent is missing from the filtered list still
50
- // gets included rather than silently dropped. Shouldn't happen in practice.
51
49
  for (const [parent, slots] of slotsByParent) {
52
50
  if (!nonSlotSet.has(parent)) result.push(...slots);
53
51
  }
@@ -1,55 +1,4 @@
1
- // React exports for browser navigation
2
-
3
- // Hook with Zustand-style selectors
4
- export { useNavigation } from "./use-navigation.js";
5
-
6
- // Router actions hook (stable reference, no re-renders)
7
- export { useRouter } from "./use-router.js";
8
-
9
- // URL hooks
10
- export { usePathname } from "./use-pathname.js";
11
- export { useSearchParams } from "./use-search-params.js";
12
- export { useParams } from "./use-params.js";
13
-
14
- // Action state tracking hook
15
- export { useAction, type TrackedActionState } from "./use-action.js";
16
-
17
- // Segments state hook
18
- export { useSegments, type SegmentsState } from "./use-segments.js";
19
-
20
- // Handle data hook
21
- export { useHandle } from "./use-handle.js";
22
-
23
- // Mount-aware reverse hook
24
- export { useReverse } from "./use-reverse.js";
25
-
26
- // Client cache controls hook
27
- export {
28
- useClientCache,
29
- type ClientCacheControls,
30
- } from "./use-client-cache.js";
31
-
32
- // Provider
33
1
  export {
34
2
  NavigationProvider,
35
3
  type NavigationProviderProps,
36
4
  } from "./NavigationProvider.js";
37
-
38
- // Context (for advanced usage)
39
- export {
40
- NavigationStoreContext,
41
- type NavigationStoreContextValue,
42
- } from "./context.js";
43
-
44
- // Link component
45
- export { Link, type LinkProps, type PrefetchStrategy } from "./Link.js";
46
-
47
- // Link status hook
48
- export { useLinkStatus, type LinkStatus } from "./use-link-status.js";
49
-
50
- // Scroll restoration
51
- export {
52
- ScrollRestoration,
53
- useScrollRestoration,
54
- type ScrollRestorationProps,
55
- } from "./ScrollRestoration.js";
@@ -1,8 +1,3 @@
1
- /**
2
- * Shared location state utilities - works in both RSC and client contexts
3
- * No "use client" directive so it can be imported from RSC
4
- */
5
-
6
1
  import type { ReactElement } from "react";
7
2
 
8
3
  /**
@@ -26,16 +21,8 @@ export interface LocationStateOptions {
26
21
 
27
22
  type LocationStateUnsafeFn = (...args: never[]) => unknown;
28
23
 
29
- // Broadest constructor signature (`abstract` covers both abstract and concrete
30
- // classes). A class passed as state has a `new` signature, not a call signature,
31
- // so it slips past LocationStateUnsafeFn; at runtime the lazy-getter path
32
- // (`typeof value === "function"`) then mistakes it for a getter and throws.
33
24
  type LocationStateUnsafeCtor = abstract new (...args: never[]) => unknown;
34
25
 
35
- // `unknown` cannot be verified serializable, so it is rejected (callers must
36
- // supply a concrete type). `any` deliberately defeats type checking and is NOT
37
- // guardable — it is assignable to the branded error too, so the check always
38
- // passes; it remains an explicit escape hatch.
39
26
  type IsAny<T> = 0 extends 1 & T ? true : false;
40
27
  type IsUnknown<T> =
41
28
  IsAny<T> extends true ? false : unknown extends T ? true : false;
@@ -3,7 +3,6 @@
3
3
  import { useState, useEffect, useRef } from "react";
4
4
  import type { LocationStateDefinition } from "./location-state-shared.js";
5
5
 
6
- // Re-export shared utilities and types
7
6
  export {
8
7
  createLocationState,
9
8
  isLocationStateEntry,
@@ -24,32 +24,24 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
24
24
  result: null,
25
25
  };
26
26
 
27
- /**
28
- * Normalize action ID - returns the ID as-is
29
- *
30
- * Server actions have IDs like "hash#actionName" or "src/actions.ts#actionName".
31
- * When using function references, we use the full ID for exact matching.
32
- * When using strings, the event controller supports suffix matching
33
- * (e.g., "addToCart" matches "hash#addToCart").
34
- */
35
- function normalizeActionId(actionId: string): string {
36
- return actionId;
37
- }
38
-
39
27
  /**
40
28
  * Extract action ID from a server action function or string.
41
29
  *
42
30
  * Actions passed as props from server components lose their metadata
43
31
  * during RSC serialization - use a string action name instead.
32
+ *
33
+ * The extracted $$id (e.g. "hash#actionName" or "src/actions.ts#actionName")
34
+ * is returned as-is. Suffix-vs-exact matching against this ID happens
35
+ * downstream in the event controller, not here.
44
36
  */
45
- export function getActionId(action: ServerActionFunction | string): string {
37
+ function getActionId(action: ServerActionFunction | string): string {
46
38
  invariant(
47
39
  typeof action === "function" || typeof action === "string",
48
40
  `useAction: action must be a function or string, got ${typeof action}`,
49
41
  );
50
42
  const actionId = (action as any)?.$$id;
51
43
  if (actionId) {
52
- return normalizeActionId(actionId);
44
+ return actionId;
53
45
  }
54
46
 
55
47
  // If action is a string, use it directly
@@ -162,7 +154,6 @@ export function useAction<T>(
162
154
  });
163
155
  const prevSelected = useRef(baseState);
164
156
  prevSelected.current = baseState;
165
- // useOptimistic allows immediate updates during transitions/actions
166
157
  const [optimisticState, setOptimisticState] = useOptimistic<
167
158
  T | TrackedActionState
168
159
  >(null!);
@@ -43,7 +43,6 @@ export function useHandle<T, A, S>(
43
43
  ): Rango.FlightSerialize<A> | S {
44
44
  const ctx = useContext(NavigationStoreContext);
45
45
 
46
- // Initial state from context event controller, or empty fallback without provider.
47
46
  const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
48
47
  if (!ctx) {
49
48
  const collected = collectHandleData(
@@ -54,7 +53,6 @@ export function useHandle<T, A, S>(
54
53
  return selector ? selector(collected) : collected;
55
54
  }
56
55
 
57
- // On client, use event controller state
58
56
  const state = ctx.eventController.getHandleState();
59
57
  const collected = collectHandleData(
60
58
  handle,
@@ -65,15 +63,12 @@ export function useHandle<T, A, S>(
65
63
  });
66
64
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
67
65
 
68
- // Track previous value for shallow comparison
69
66
  const prevValueRef = useRef(value);
70
67
  prevValueRef.current = value;
71
68
 
72
- // Ref keeps the latest selector without re-subscribing on every render.
73
69
  const selectorRef = useRef(selector);
74
70
  selectorRef.current = selector;
75
71
 
76
- // Subscribe to handle data changes (client only)
77
72
  useEffect(() => {
78
73
  if (!ctx) return;
79
74
 
@@ -82,11 +82,9 @@ export function useLinkStatus(): LinkStatus {
82
82
  const linkTo = useContext(LinkContext);
83
83
  const ctx = useContext(NavigationStoreContext);
84
84
 
85
- // Get origin for URL normalization (stable across renders)
86
85
  const origin =
87
86
  typeof window !== "undefined" ? window.location.origin : "http://localhost";
88
87
 
89
- // Base state for useOptimistic
90
88
  const [basePending, setBasePending] = useState<boolean>(() => {
91
89
  if (!ctx || linkTo === null) {
92
90
  return false;
@@ -97,7 +95,6 @@ export function useLinkStatus(): LinkStatus {
97
95
 
98
96
  const prevPending = useRef(basePending);
99
97
 
100
- // useOptimistic allows immediate updates during transitions
101
98
  const [pending, setOptimisticPending] = useOptimistic(basePending);
102
99
 
103
100
  useEffect(() => {
@@ -105,7 +102,6 @@ export function useLinkStatus(): LinkStatus {
105
102
  return;
106
103
  }
107
104
 
108
- // Subscribe to navigation state changes
109
105
  return ctx.eventController.subscribe(() => {
110
106
  const state = ctx.eventController.getState();
111
107
  const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
@@ -46,7 +46,6 @@ export function useNavigation<T>(
46
46
  throw new Error("useNavigation must be used within NavigationProvider");
47
47
  }
48
48
 
49
- // Base state for useOptimistic
50
49
  const [baseValue, setBaseValue] = useState<T | PublicNavigationState>(() => {
51
50
  const publicState = toPublicState(ctx.eventController.getState());
52
51
  return selector ? selector(publicState) : publicState;
@@ -59,7 +58,6 @@ export function useNavigation<T>(
59
58
  // parent transition (e.g. <Link> click) is still pending.
60
59
  const optimisticPinnedRef = useRef(false);
61
60
 
62
- // useOptimistic allows immediate updates during transitions/actions
63
61
  const [value, setOptimisticValue] = useOptimistic(baseValue);
64
62
 
65
63
  // Store selector in a ref so the subscription callback always uses the
@@ -72,7 +70,6 @@ export function useNavigation<T>(
72
70
 
73
71
  // Subscribe to event controller state changes (only runs on client)
74
72
  useEffect(() => {
75
- // Subscribe to updates from event controller
76
73
  return ctx.eventController.subscribe(() => {
77
74
  const currentState = ctx.eventController.getState();
78
75
  const publicState = toPublicState(currentState);
@@ -50,8 +50,6 @@ export function useParams<T>(
50
50
  });
51
51
 
52
52
  const prevValue = useRef(value);
53
- // Ref keeps the latest selector without re-subscribing. Event-driven by
54
- // design: value updates on store events, not on selector identity change.
55
53
  const selectorRef = useRef(selector);
56
54
  selectorRef.current = selector;
57
55
 
@@ -24,9 +24,6 @@ import type { ReadonlyURLSearchParams } from "../types.js";
24
24
  export function useSearchParams(): ReadonlyURLSearchParams {
25
25
  const ctx = useContext(NavigationStoreContext);
26
26
 
27
- // Always initialize with empty URLSearchParams to match SSR output
28
- // and avoid hydration mismatch. The useEffect below syncs from
29
- // the real URL after hydration.
30
27
  const [searchParams, setSearchParams] = useState<ReadonlyURLSearchParams>(
31
28
  () => new URLSearchParams(),
32
29
  );
@@ -41,12 +38,10 @@ export function useSearchParams(): ReadonlyURLSearchParams {
41
38
  const nextSearch = location.searchParams.toString();
42
39
  if (nextSearch !== prevSearch.current) {
43
40
  prevSearch.current = nextSearch;
44
- // Create a snapshot so callers cannot mutate the source URLSearchParams
45
41
  setSearchParams(new URLSearchParams(nextSearch));
46
42
  }
47
43
  };
48
44
 
49
- // Sync on mount (picks up search params from browser URL)
50
45
  update();
51
46
 
52
47
  return ctx.eventController.subscribe(update);
@@ -86,31 +86,20 @@ export function useSegments<T>(
86
86
  const selectorRef = useRef(selector);
87
87
  selectorRef.current = selector;
88
88
 
89
- // Track selector identity to detect when the selector function changes.
90
- // Only then do we eagerly recompute during render to avoid staleness.
91
- // Without this guard, no-selector mode causes infinite re-renders because
92
- // buildSegmentsState creates fresh arrays that fail Object.is checks.
93
89
  const prevSelectorIdentity = useRef(selector);
94
90
 
95
- // Cache SegmentsState to stabilize nested references (path, segmentIds
96
- // arrays) so selectors returning composite values don't cause spurious
97
- // render-time setState calls.
98
91
  const segmentsCache = useRef<{
99
92
  location: URL;
100
93
  routeSegmentIds: string[];
101
94
  state: SegmentsState;
102
95
  } | null>(null);
103
96
 
104
- // Recompute selected value from current store state and apply selector.
105
- // Shared by the render-time eager check and the subscription callback.
106
97
  function recompute(
107
98
  sel: ((state: SegmentsState) => T) | undefined,
108
99
  ): T | SegmentsState {
109
100
  const location = ctx!.eventController.getLocation();
110
101
  const handleState = ctx!.eventController.getHandleState();
111
102
 
112
- // Reuse cached state when inputs haven't changed by reference,
113
- // keeping array/object references stable for composite selectors.
114
103
  const cache = segmentsCache.current;
115
104
  let segmentsState: SegmentsState;
116
105
  if (
@@ -165,8 +154,6 @@ export function useSegments<T>(
165
154
  unsubscribeNav();
166
155
  unsubscribeHandles();
167
156
  };
168
- // Stable subscription: selector changes are handled via selectorRef,
169
- // state comparison uses prevState ref. No re-subscribe needed.
170
157
  // eslint-disable-next-line react-hooks/exhaustive-deps
171
158
  }, []);
172
159