@rangojs/router 0.0.0-experimental.002d056c

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 (305) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +899 -0
  3. package/dist/bin/rango.js +1606 -0
  4. package/dist/vite/index.js +5153 -0
  5. package/package.json +177 -0
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +262 -0
  8. package/skills/caching/SKILL.md +253 -0
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +112 -0
  11. package/skills/document-cache/SKILL.md +182 -0
  12. package/skills/fonts/SKILL.md +167 -0
  13. package/skills/hooks/SKILL.md +704 -0
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +313 -0
  16. package/skills/layout/SKILL.md +310 -0
  17. package/skills/links/SKILL.md +239 -0
  18. package/skills/loader/SKILL.md +596 -0
  19. package/skills/middleware/SKILL.md +339 -0
  20. package/skills/mime-routes/SKILL.md +128 -0
  21. package/skills/parallel/SKILL.md +305 -0
  22. package/skills/prerender/SKILL.md +643 -0
  23. package/skills/rango/SKILL.md +118 -0
  24. package/skills/response-routes/SKILL.md +411 -0
  25. package/skills/route/SKILL.md +385 -0
  26. package/skills/router-setup/SKILL.md +439 -0
  27. package/skills/tailwind/SKILL.md +129 -0
  28. package/skills/theme/SKILL.md +79 -0
  29. package/skills/typesafety/SKILL.md +623 -0
  30. package/skills/use-cache/SKILL.md +324 -0
  31. package/src/__internal.ts +273 -0
  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/event-controller.ts +899 -0
  36. package/src/browser/history-state.ts +80 -0
  37. package/src/browser/index.ts +18 -0
  38. package/src/browser/intercept-utils.ts +52 -0
  39. package/src/browser/link-interceptor.ts +141 -0
  40. package/src/browser/logging.ts +55 -0
  41. package/src/browser/merge-segment-loaders.ts +134 -0
  42. package/src/browser/navigation-bridge.ts +638 -0
  43. package/src/browser/navigation-client.ts +261 -0
  44. package/src/browser/navigation-store.ts +806 -0
  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 +582 -0
  48. package/src/browser/prefetch/cache.ts +206 -0
  49. package/src/browser/prefetch/fetch.ts +145 -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 +128 -0
  53. package/src/browser/rango-state.ts +112 -0
  54. package/src/browser/react/Link.tsx +368 -0
  55. package/src/browser/react/NavigationProvider.tsx +413 -0
  56. package/src/browser/react/ScrollRestoration.tsx +94 -0
  57. package/src/browser/react/context.ts +59 -0
  58. package/src/browser/react/filter-segment-order.ts +11 -0
  59. package/src/browser/react/index.ts +52 -0
  60. package/src/browser/react/location-state-shared.ts +162 -0
  61. package/src/browser/react/location-state.ts +107 -0
  62. package/src/browser/react/mount-context.ts +37 -0
  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 +218 -0
  66. package/src/browser/react/use-client-cache.ts +58 -0
  67. package/src/browser/react/use-handle.ts +162 -0
  68. package/src/browser/react/use-href.tsx +40 -0
  69. package/src/browser/react/use-link-status.ts +135 -0
  70. package/src/browser/react/use-mount.ts +31 -0
  71. package/src/browser/react/use-navigation.ts +99 -0
  72. package/src/browser/react/use-params.ts +65 -0
  73. package/src/browser/react/use-pathname.ts +47 -0
  74. package/src/browser/react/use-router.ts +63 -0
  75. package/src/browser/react/use-search-params.ts +56 -0
  76. package/src/browser/react/use-segments.ts +171 -0
  77. package/src/browser/response-adapter.ts +73 -0
  78. package/src/browser/rsc-router.tsx +464 -0
  79. package/src/browser/scroll-restoration.ts +397 -0
  80. package/src/browser/segment-reconciler.ts +216 -0
  81. package/src/browser/segment-structure-assert.ts +83 -0
  82. package/src/browser/server-action-bridge.ts +667 -0
  83. package/src/browser/shallow.ts +40 -0
  84. package/src/browser/types.ts +547 -0
  85. package/src/browser/validate-redirect-origin.ts +29 -0
  86. package/src/build/generate-manifest.ts +438 -0
  87. package/src/build/generate-route-types.ts +36 -0
  88. package/src/build/index.ts +35 -0
  89. package/src/build/route-trie.ts +265 -0
  90. package/src/build/route-types/ast-helpers.ts +25 -0
  91. package/src/build/route-types/ast-route-extraction.ts +98 -0
  92. package/src/build/route-types/codegen.ts +102 -0
  93. package/src/build/route-types/include-resolution.ts +411 -0
  94. package/src/build/route-types/param-extraction.ts +48 -0
  95. package/src/build/route-types/per-module-writer.ts +128 -0
  96. package/src/build/route-types/router-processing.ts +479 -0
  97. package/src/build/route-types/scan-filter.ts +78 -0
  98. package/src/build/runtime-discovery.ts +231 -0
  99. package/src/cache/background-task.ts +34 -0
  100. package/src/cache/cache-key-utils.ts +44 -0
  101. package/src/cache/cache-policy.ts +125 -0
  102. package/src/cache/cache-runtime.ts +338 -0
  103. package/src/cache/cache-scope.ts +382 -0
  104. package/src/cache/cf/cf-cache-store.ts +982 -0
  105. package/src/cache/cf/index.ts +29 -0
  106. package/src/cache/document-cache.ts +369 -0
  107. package/src/cache/handle-capture.ts +81 -0
  108. package/src/cache/handle-snapshot.ts +41 -0
  109. package/src/cache/index.ts +44 -0
  110. package/src/cache/memory-segment-store.ts +328 -0
  111. package/src/cache/profile-registry.ts +73 -0
  112. package/src/cache/read-through-swr.ts +134 -0
  113. package/src/cache/segment-codec.ts +256 -0
  114. package/src/cache/taint.ts +98 -0
  115. package/src/cache/types.ts +342 -0
  116. package/src/client.rsc.tsx +85 -0
  117. package/src/client.tsx +601 -0
  118. package/src/component-utils.ts +76 -0
  119. package/src/components/DefaultDocument.tsx +27 -0
  120. package/src/context-var.ts +86 -0
  121. package/src/debug.ts +243 -0
  122. package/src/default-error-boundary.tsx +88 -0
  123. package/src/deps/browser.ts +8 -0
  124. package/src/deps/html-stream-client.ts +2 -0
  125. package/src/deps/html-stream-server.ts +2 -0
  126. package/src/deps/rsc.ts +10 -0
  127. package/src/deps/ssr.ts +2 -0
  128. package/src/errors.ts +365 -0
  129. package/src/handle.ts +135 -0
  130. package/src/handles/MetaTags.tsx +246 -0
  131. package/src/handles/breadcrumbs.ts +66 -0
  132. package/src/handles/index.ts +7 -0
  133. package/src/handles/meta.ts +264 -0
  134. package/src/host/cookie-handler.ts +165 -0
  135. package/src/host/errors.ts +97 -0
  136. package/src/host/index.ts +53 -0
  137. package/src/host/pattern-matcher.ts +214 -0
  138. package/src/host/router.ts +352 -0
  139. package/src/host/testing.ts +79 -0
  140. package/src/host/types.ts +146 -0
  141. package/src/host/utils.ts +25 -0
  142. package/src/href-client.ts +222 -0
  143. package/src/index.rsc.ts +233 -0
  144. package/src/index.ts +277 -0
  145. package/src/internal-debug.ts +11 -0
  146. package/src/loader.rsc.ts +89 -0
  147. package/src/loader.ts +64 -0
  148. package/src/network-error-thrower.tsx +23 -0
  149. package/src/outlet-context.ts +15 -0
  150. package/src/outlet-provider.tsx +45 -0
  151. package/src/prerender/param-hash.ts +37 -0
  152. package/src/prerender/store.ts +185 -0
  153. package/src/prerender.ts +463 -0
  154. package/src/reverse.ts +330 -0
  155. package/src/root-error-boundary.tsx +289 -0
  156. package/src/route-content-wrapper.tsx +196 -0
  157. package/src/route-definition/dsl-helpers.ts +934 -0
  158. package/src/route-definition/helper-factories.ts +200 -0
  159. package/src/route-definition/helpers-types.ts +430 -0
  160. package/src/route-definition/index.ts +52 -0
  161. package/src/route-definition/redirect.ts +93 -0
  162. package/src/route-definition.ts +1 -0
  163. package/src/route-map-builder.ts +281 -0
  164. package/src/route-name.ts +53 -0
  165. package/src/route-types.ts +259 -0
  166. package/src/router/content-negotiation.ts +116 -0
  167. package/src/router/debug-manifest.ts +72 -0
  168. package/src/router/error-handling.ts +287 -0
  169. package/src/router/find-match.ts +160 -0
  170. package/src/router/handler-context.ts +451 -0
  171. package/src/router/intercept-resolution.ts +397 -0
  172. package/src/router/lazy-includes.ts +236 -0
  173. package/src/router/loader-resolution.ts +420 -0
  174. package/src/router/logging.ts +251 -0
  175. package/src/router/manifest.ts +269 -0
  176. package/src/router/match-api.ts +620 -0
  177. package/src/router/match-context.ts +266 -0
  178. package/src/router/match-handlers.ts +440 -0
  179. package/src/router/match-middleware/background-revalidation.ts +223 -0
  180. package/src/router/match-middleware/cache-lookup.ts +634 -0
  181. package/src/router/match-middleware/cache-store.ts +295 -0
  182. package/src/router/match-middleware/index.ts +81 -0
  183. package/src/router/match-middleware/intercept-resolution.ts +306 -0
  184. package/src/router/match-middleware/segment-resolution.ts +193 -0
  185. package/src/router/match-pipelines.ts +179 -0
  186. package/src/router/match-result.ts +219 -0
  187. package/src/router/metrics.ts +282 -0
  188. package/src/router/middleware-cookies.ts +55 -0
  189. package/src/router/middleware-types.ts +222 -0
  190. package/src/router/middleware.ts +749 -0
  191. package/src/router/pattern-matching.ts +563 -0
  192. package/src/router/prerender-match.ts +402 -0
  193. package/src/router/preview-match.ts +170 -0
  194. package/src/router/revalidation.ts +289 -0
  195. package/src/router/router-context.ts +320 -0
  196. package/src/router/router-interfaces.ts +452 -0
  197. package/src/router/router-options.ts +592 -0
  198. package/src/router/router-registry.ts +24 -0
  199. package/src/router/segment-resolution/fresh.ts +570 -0
  200. package/src/router/segment-resolution/helpers.ts +263 -0
  201. package/src/router/segment-resolution/loader-cache.ts +198 -0
  202. package/src/router/segment-resolution/revalidation.ts +1242 -0
  203. package/src/router/segment-resolution/static-store.ts +67 -0
  204. package/src/router/segment-resolution.ts +21 -0
  205. package/src/router/segment-wrappers.ts +291 -0
  206. package/src/router/telemetry-otel.ts +299 -0
  207. package/src/router/telemetry.ts +300 -0
  208. package/src/router/timeout.ts +148 -0
  209. package/src/router/trie-matching.ts +239 -0
  210. package/src/router/types.ts +170 -0
  211. package/src/router.ts +1006 -0
  212. package/src/rsc/handler-context.ts +45 -0
  213. package/src/rsc/handler.ts +1089 -0
  214. package/src/rsc/helpers.ts +198 -0
  215. package/src/rsc/index.ts +36 -0
  216. package/src/rsc/loader-fetch.ts +209 -0
  217. package/src/rsc/manifest-init.ts +86 -0
  218. package/src/rsc/nonce.ts +32 -0
  219. package/src/rsc/origin-guard.ts +141 -0
  220. package/src/rsc/progressive-enhancement.ts +379 -0
  221. package/src/rsc/response-error.ts +37 -0
  222. package/src/rsc/response-route-handler.ts +347 -0
  223. package/src/rsc/rsc-rendering.ts +237 -0
  224. package/src/rsc/runtime-warnings.ts +42 -0
  225. package/src/rsc/server-action.ts +348 -0
  226. package/src/rsc/ssr-setup.ts +128 -0
  227. package/src/rsc/types.ts +263 -0
  228. package/src/search-params.ts +230 -0
  229. package/src/segment-system.tsx +454 -0
  230. package/src/server/context.ts +591 -0
  231. package/src/server/cookie-store.ts +190 -0
  232. package/src/server/fetchable-loader-store.ts +37 -0
  233. package/src/server/handle-store.ts +308 -0
  234. package/src/server/loader-registry.ts +133 -0
  235. package/src/server/request-context.ts +920 -0
  236. package/src/server/root-layout.tsx +10 -0
  237. package/src/server/tsconfig.json +14 -0
  238. package/src/server.ts +51 -0
  239. package/src/ssr/index.tsx +365 -0
  240. package/src/static-handler.ts +114 -0
  241. package/src/theme/ThemeProvider.tsx +297 -0
  242. package/src/theme/ThemeScript.tsx +61 -0
  243. package/src/theme/constants.ts +62 -0
  244. package/src/theme/index.ts +48 -0
  245. package/src/theme/theme-context.ts +44 -0
  246. package/src/theme/theme-script.ts +155 -0
  247. package/src/theme/types.ts +182 -0
  248. package/src/theme/use-theme.ts +44 -0
  249. package/src/types/boundaries.ts +158 -0
  250. package/src/types/cache-types.ts +198 -0
  251. package/src/types/error-types.ts +192 -0
  252. package/src/types/global-namespace.ts +100 -0
  253. package/src/types/handler-context.ts +687 -0
  254. package/src/types/index.ts +88 -0
  255. package/src/types/loader-types.ts +183 -0
  256. package/src/types/route-config.ts +170 -0
  257. package/src/types/route-entry.ts +109 -0
  258. package/src/types/segments.ts +148 -0
  259. package/src/types.ts +1 -0
  260. package/src/urls/include-helper.ts +197 -0
  261. package/src/urls/index.ts +53 -0
  262. package/src/urls/path-helper-types.ts +339 -0
  263. package/src/urls/path-helper.ts +329 -0
  264. package/src/urls/pattern-types.ts +95 -0
  265. package/src/urls/response-types.ts +106 -0
  266. package/src/urls/type-extraction.ts +372 -0
  267. package/src/urls/urls-function.ts +98 -0
  268. package/src/urls.ts +1 -0
  269. package/src/use-loader.tsx +354 -0
  270. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  271. package/src/vite/discovery/discover-routers.ts +344 -0
  272. package/src/vite/discovery/prerender-collection.ts +385 -0
  273. package/src/vite/discovery/route-types-writer.ts +258 -0
  274. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  275. package/src/vite/discovery/state.ts +108 -0
  276. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  277. package/src/vite/index.ts +16 -0
  278. package/src/vite/plugin-types.ts +48 -0
  279. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  280. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  281. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  282. package/src/vite/plugins/expose-action-id.ts +363 -0
  283. package/src/vite/plugins/expose-id-utils.ts +287 -0
  284. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  285. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  286. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  287. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  288. package/src/vite/plugins/expose-ids/types.ts +45 -0
  289. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  290. package/src/vite/plugins/refresh-cmd.ts +65 -0
  291. package/src/vite/plugins/use-cache-transform.ts +323 -0
  292. package/src/vite/plugins/version-injector.ts +83 -0
  293. package/src/vite/plugins/version-plugin.ts +266 -0
  294. package/src/vite/plugins/version.d.ts +12 -0
  295. package/src/vite/plugins/virtual-entries.ts +123 -0
  296. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  297. package/src/vite/rango.ts +445 -0
  298. package/src/vite/router-discovery.ts +777 -0
  299. package/src/vite/utils/ast-handler-extract.ts +517 -0
  300. package/src/vite/utils/banner.ts +36 -0
  301. package/src/vite/utils/bundle-analysis.ts +137 -0
  302. package/src/vite/utils/manifest-utils.ts +70 -0
  303. package/src/vite/utils/package-resolution.ts +121 -0
  304. package/src/vite/utils/prerender-utils.ts +189 -0
  305. package/src/vite/utils/shared-utils.ts +169 -0
@@ -0,0 +1,806 @@
1
+ import type {
2
+ NavigationState,
3
+ NavigationLocation,
4
+ SegmentState,
5
+ NavigationStore,
6
+ NavigationUpdate,
7
+ UpdateSubscriber,
8
+ StateListener,
9
+ ResolvedSegment,
10
+ InflightAction,
11
+ TrackedActionState,
12
+ ActionStateListener,
13
+ HandleData,
14
+ } from "./types.js";
15
+ import { clearPrefetchCache } from "./prefetch/cache.js";
16
+
17
+ /**
18
+ * Default action state (idle with no payload)
19
+ */
20
+ const DEFAULT_ACTION_STATE: TrackedActionState = {
21
+ state: "idle",
22
+ actionId: null,
23
+ payload: null,
24
+ error: null,
25
+ result: null,
26
+ };
27
+
28
+ // Maximum number of history entries to cache (URLs visited)
29
+ const HISTORY_CACHE_SIZE = 20;
30
+
31
+ // Cache entry: [url-key, segments, stale, handleData?]
32
+ // stale=true means the data may be outdated and should be revalidated on access
33
+ type HistoryCacheEntry = [string, ResolvedSegment[], boolean, HandleData?];
34
+
35
+ /**
36
+ * Shallow clone handleData to avoid reference sharing between cache entries.
37
+ * Only clones the structure (objects and arrays), not the data items themselves,
38
+ * since mutations happen at the array level, not on individual data objects.
39
+ * This preserves any non-serializable types (React elements, functions, etc.)
40
+ */
41
+ function cloneHandleData(handleData: HandleData): HandleData {
42
+ const cloned: HandleData = {};
43
+ for (const [handleKey, segmentMap] of Object.entries(handleData)) {
44
+ cloned[handleKey] = {};
45
+ for (const [segmentId, dataArray] of Object.entries(segmentMap)) {
46
+ cloned[handleKey][segmentId] = [...dataArray];
47
+ }
48
+ }
49
+ return cloned;
50
+ }
51
+
52
+ // BroadcastChannel for cross-tab cache invalidation
53
+ const CACHE_INVALIDATION_CHANNEL = "rsc-router-cache-invalidation";
54
+
55
+ // BroadcastChannel instance (lazily initialized)
56
+ let cacheInvalidationChannel: BroadcastChannel | null = null;
57
+
58
+ /**
59
+ * Get or create the BroadcastChannel for cache invalidation
60
+ */
61
+ function getCacheInvalidationChannel(): BroadcastChannel | null {
62
+ if (
63
+ typeof window === "undefined" ||
64
+ typeof BroadcastChannel === "undefined"
65
+ ) {
66
+ return null;
67
+ }
68
+ if (!cacheInvalidationChannel) {
69
+ cacheInvalidationChannel = new BroadcastChannel(CACHE_INVALIDATION_CHANNEL);
70
+ }
71
+ return cacheInvalidationChannel;
72
+ }
73
+
74
+ /**
75
+ * Options for generating a history key
76
+ */
77
+ export interface HistoryKeyOptions {
78
+ /** If true, append :intercept suffix to differentiate intercept entries */
79
+ intercept?: boolean;
80
+ }
81
+
82
+ /**
83
+ * Generate a cache key from a URL.
84
+ * Uses pathname + search (query params) directly as the key.
85
+ * Hash fragments (#) are excluded since they don't affect server data.
86
+ *
87
+ * For intercept routes, append `:intercept` suffix to cache them separately
88
+ * from non-intercept versions of the same URL.
89
+ */
90
+ export function generateHistoryKey(
91
+ url?: string,
92
+ options?: HistoryKeyOptions,
93
+ ): string {
94
+ if (!url) {
95
+ url = typeof window !== "undefined" ? window.location.href : "/";
96
+ }
97
+
98
+ // Parse URL and use only pathname + search (exclude hash fragment)
99
+ const parsed = new URL(url, "http://localhost");
100
+ let key = parsed.pathname + parsed.search;
101
+
102
+ // Append intercept suffix for separate caching
103
+ if (options?.intercept) {
104
+ key += ":intercept";
105
+ }
106
+
107
+ return key;
108
+ }
109
+
110
+ /**
111
+ * Configuration for creating a navigation store
112
+ */
113
+ export interface NavigationStoreConfig {
114
+ initialLocation?: { href: string };
115
+ initialSegmentIds?: string[];
116
+ initialHistoryKey?: string;
117
+ initialSegments?: ResolvedSegment[];
118
+
119
+ /**
120
+ * Maximum number of history entries to cache (default: 20)
121
+ * Older entries are evicted when limit is reached
122
+ */
123
+ cacheSize?: number;
124
+
125
+ /**
126
+ * Enable cross-tab cache invalidation via BroadcastChannel (default: true)
127
+ * When cache is cleared (via server actions or useClientCache().clear()),
128
+ * other tabs will also clear their cache
129
+ */
130
+ crossTabSync?: boolean;
131
+
132
+ /**
133
+ * Auto-refresh when another tab mutates data on the same path (default: true)
134
+ * Triggered when cache is cleared via server actions or useClientCache().clear()
135
+ * Requires crossTabSync to be enabled
136
+ */
137
+ crossTabAutoRefresh?: boolean;
138
+
139
+ /**
140
+ * Callback to invoke when cross-tab refresh is triggered
141
+ * Called when another tab invalidates the cache for a related route
142
+ */
143
+ onCrossTabRefresh?: () => void;
144
+ }
145
+
146
+ /**
147
+ * Create a URL instance from window.location or custom values
148
+ */
149
+ function createLocation(loc: { href: string }): NavigationLocation {
150
+ return new URL(loc.href);
151
+ }
152
+
153
+ /**
154
+ * Create a navigation store for managing browser-side navigation state
155
+ *
156
+ * The store manages two types of state:
157
+ * - NavigationState: Public state exposed via useNavigation hook
158
+ * - SegmentState: Internal segment management for partial RSC updates
159
+ *
160
+ * @param config - Initial configuration
161
+ * @returns NavigationStore instance
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * const store = createNavigationStore({
166
+ * initialLocation: window.location,
167
+ * initialSegmentIds: [],
168
+ * });
169
+ *
170
+ * // Subscribe to state changes (for useNavigation hook)
171
+ * const unsubscribe = store.subscribe(() => {
172
+ * const state = store.getState();
173
+ * console.log('Navigation state:', state);
174
+ * });
175
+ *
176
+ * // Update state
177
+ * store.setState({ state: 'loading' });
178
+ *
179
+ * // Subscribe to UI updates (for re-rendering)
180
+ * store.onUpdate((update) => {
181
+ * console.log('New root:', update.root);
182
+ * });
183
+ * ```
184
+ */
185
+ export function createNavigationStore(
186
+ config?: NavigationStoreConfig,
187
+ ): NavigationStore {
188
+ // Default location from window or config
189
+ const defaultLocation: NavigationLocation =
190
+ typeof window !== "undefined"
191
+ ? createLocation(window.location)
192
+ : new URL("/", "http://localhost");
193
+
194
+ // Public navigation state (for useNavigation hook)
195
+ // isStreaming starts false to match SSR and avoid hydration mismatch
196
+ // After hydration, entry.browser.tsx sets it to true if stream is still open
197
+ let navState: NavigationState = {
198
+ state: "idle",
199
+ isStreaming: false,
200
+ location: config?.initialLocation
201
+ ? createLocation(config.initialLocation)
202
+ : defaultLocation,
203
+ pendingUrl: null,
204
+ inflightActions: [],
205
+ };
206
+
207
+ // Resolve the initial location for segment state
208
+ const initialLoc = config?.initialLocation
209
+ ? createLocation(config.initialLocation)
210
+ : defaultLocation;
211
+
212
+ // Internal segment state (for partial updates)
213
+ const segmentState: SegmentState = {
214
+ path: initialLoc.pathname,
215
+ currentUrl: initialLoc.href,
216
+ currentSegmentIds: config?.initialSegmentIds ?? [],
217
+ };
218
+
219
+ // Configuration with defaults
220
+ const cacheSize = config?.cacheSize ?? HISTORY_CACHE_SIZE;
221
+ const crossTabSync = config?.crossTabSync !== false; // Default: true
222
+ const crossTabAutoRefresh = config?.crossTabAutoRefresh !== false; // Default: true
223
+
224
+ // Cross-tab refresh callback (set by navigation bridge)
225
+ let crossTabRefreshCallback: (() => void) | null =
226
+ config?.onCrossTabRefresh ?? null;
227
+
228
+ // Track pending cross-tab refresh to prevent duplicate refreshes
229
+ let pendingCrossTabRefresh = false;
230
+
231
+ // History-based segment cache: array of [url-key, segments] tuples
232
+ // Each URL gets its own complete snapshot of segments for back/forward and partial merging
233
+ // Oldest entries (at front) are removed when over cacheSize limit
234
+ const historyCache: HistoryCacheEntry[] = [];
235
+
236
+ // Current history key (set on navigation, stored in history.state)
237
+ let currentHistoryKey = config?.initialHistoryKey || generateHistoryKey();
238
+
239
+ // Store initial segments if provided (not stale)
240
+ if (config?.initialHistoryKey && config?.initialSegments) {
241
+ historyCache.push([
242
+ config.initialHistoryKey,
243
+ config.initialSegments,
244
+ false,
245
+ ]);
246
+ }
247
+
248
+ // State change listeners (for useNavigation subscriptions)
249
+ const stateListeners = new Set<StateListener>();
250
+
251
+ // UI update subscribers (for re-rendering)
252
+ const updateSubscribers = new Set<UpdateSubscriber>();
253
+
254
+ // Internal flag to track if a server action is in progress
255
+ let actionInProgress = false;
256
+
257
+ // Intercept source URL - tracks where the intercept was triggered from
258
+ // Used to maintain intercept context during action revalidation
259
+ let interceptSourceUrl: string | null = null;
260
+
261
+ // Action state tracking (for useAction hook)
262
+ // Maps action function ID to its tracked state
263
+ const actionStates = new Map<string, TrackedActionState>();
264
+
265
+ // Action state listeners (per action ID)
266
+ // Maps action function ID to set of listeners
267
+ const actionListeners = new Map<string, Set<ActionStateListener>>();
268
+
269
+ /**
270
+ * Create a debounced function that batches rapid calls
271
+ */
272
+ function createDebouncedNotifier<T extends (...args: any[]) => void>(
273
+ fn: T,
274
+ ms: number = 20,
275
+ ): T {
276
+ let timeout: ReturnType<typeof setTimeout> | null = null;
277
+ return ((...args: Parameters<T>) => {
278
+ if (timeout !== null) clearTimeout(timeout);
279
+ timeout = setTimeout(() => {
280
+ timeout = null;
281
+ fn(...args);
282
+ }, ms);
283
+ }) as T;
284
+ }
285
+
286
+ /**
287
+ * Create a keyed debounced function (separate timers per key)
288
+ */
289
+ function createKeyedDebouncedNotifier<
290
+ T extends (key: string, ...args: any[]) => void,
291
+ >(fn: T, ms: number = 20): T {
292
+ const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
293
+ return ((key: string, ...args: any[]) => {
294
+ const existing = timeouts.get(key);
295
+ if (existing !== undefined) clearTimeout(existing);
296
+ timeouts.set(
297
+ key,
298
+ setTimeout(() => {
299
+ timeouts.delete(key);
300
+ fn(key, ...args);
301
+ }, ms),
302
+ );
303
+ }) as T;
304
+ }
305
+
306
+ const notifyStateListeners = createDebouncedNotifier(() => {
307
+ stateListeners.forEach((listener) => listener());
308
+ });
309
+
310
+ const notifyActionListeners = createKeyedDebouncedNotifier(
311
+ (actionId: string, state: TrackedActionState) => {
312
+ const listeners = actionListeners.get(actionId);
313
+ if (listeners) {
314
+ listeners.forEach((listener) => listener(state));
315
+ }
316
+ },
317
+ );
318
+
319
+ /**
320
+ * Clear the history cache (internal - does not broadcast)
321
+ */
322
+ function clearCacheInternal(): void {
323
+ historyCache.length = 0;
324
+ clearPrefetchCache();
325
+ }
326
+
327
+ /**
328
+ * Mark all cache entries as stale (internal - does not broadcast)
329
+ */
330
+ function markCacheAsStaleInternal(): void {
331
+ for (let i = 0; i < historyCache.length; i++) {
332
+ historyCache[i][2] = true;
333
+ }
334
+ clearPrefetchCache();
335
+ }
336
+
337
+ /**
338
+ * Clear the history cache and broadcast to other tabs
339
+ */
340
+ function clearCacheAndBroadcast(): void {
341
+ clearCacheInternal();
342
+ broadcastInvalidation();
343
+ }
344
+
345
+ /**
346
+ * Mark cache as stale and broadcast to other tabs
347
+ */
348
+ function markStaleAndBroadcast(): void {
349
+ markCacheAsStaleInternal();
350
+ broadcastInvalidation();
351
+ }
352
+
353
+ /**
354
+ * Broadcast cache invalidation to other tabs without clearing local cache
355
+ * Used after consolidation fetch where local cache has fresh data
356
+ */
357
+ function broadcastInvalidation(): void {
358
+ // Only broadcast if cross-tab sync is enabled
359
+ if (!crossTabSync) return;
360
+
361
+ const channel = getCacheInvalidationChannel();
362
+ if (channel) {
363
+ // Broadcast path and segment IDs - receiver checks for shared segments
364
+ const currentPath = window.location.pathname;
365
+ const currentSegmentIds = segmentState.currentSegmentIds;
366
+ channel.postMessage({
367
+ type: "invalidate",
368
+ path: currentPath,
369
+ segmentIds: currentSegmentIds,
370
+ });
371
+ }
372
+ }
373
+
374
+ // Set up cross-tab cache invalidation listener (only if enabled)
375
+ if (crossTabSync) {
376
+ const channel = getCacheInvalidationChannel();
377
+ if (channel) {
378
+ channel.onmessage = (event) => {
379
+ if (event.data?.type === "invalidate") {
380
+ const mutatedPath = event.data.path;
381
+ const mutatedSegmentIds: string[] = event.data.segmentIds ?? [];
382
+ const currentSegmentIds = segmentState.currentSegmentIds;
383
+
384
+ // Check for shared segments between tabs
385
+ // Routes sharing any segment (layout, loader, etc.) should invalidate together
386
+ const hasSharedSegment = mutatedSegmentIds.some((id) =>
387
+ currentSegmentIds.includes(id),
388
+ );
389
+
390
+ if (!hasSharedSegment) {
391
+ // No shared segments - routes are unrelated, ignore invalidation
392
+ return;
393
+ }
394
+
395
+ markCacheAsStaleInternal();
396
+
397
+ // Auto-refresh if enabled and callback is registered
398
+ if (crossTabAutoRefresh && crossTabRefreshCallback) {
399
+ // If idle, refresh immediately. If loading, wait for idle then refresh.
400
+ if (navState.state === "idle") {
401
+ crossTabRefreshCallback();
402
+ } else if (!pendingCrossTabRefresh) {
403
+ // Only queue one refresh, ignore subsequent events while loading
404
+ pendingCrossTabRefresh = true;
405
+ // Subscribe to state changes, refresh when idle
406
+ const listener: StateListener = () => {
407
+ if (navState.state === "idle") {
408
+ stateListeners.delete(listener);
409
+ pendingCrossTabRefresh = false;
410
+ crossTabRefreshCallback?.();
411
+ }
412
+ };
413
+ stateListeners.add(listener);
414
+ }
415
+ }
416
+ }
417
+ };
418
+ }
419
+ }
420
+
421
+ return {
422
+ // ========================================================================
423
+ // Public State (for useNavigation hook)
424
+ // ========================================================================
425
+
426
+ /**
427
+ * Get current navigation state
428
+ */
429
+ getState(): NavigationState {
430
+ return navState;
431
+ },
432
+
433
+ /**
434
+ * Update navigation state and notify listeners
435
+ */
436
+ setState(partial: Partial<NavigationState>): void {
437
+ navState = { ...navState, ...partial };
438
+ notifyStateListeners();
439
+ },
440
+
441
+ /**
442
+ * Subscribe to state changes
443
+ * Returns unsubscribe function
444
+ */
445
+ subscribe(listener: StateListener): () => void {
446
+ stateListeners.add(listener);
447
+ return () => {
448
+ stateListeners.delete(listener);
449
+ };
450
+ },
451
+
452
+ // ========================================================================
453
+ // Inflight Action Management
454
+ // ========================================================================
455
+
456
+ /**
457
+ * Add an inflight action to the list
458
+ */
459
+ addInflightAction(action: InflightAction): void {
460
+ navState = {
461
+ ...navState,
462
+ inflightActions: [...navState.inflightActions, action],
463
+ };
464
+ notifyStateListeners();
465
+ },
466
+
467
+ /**
468
+ * Remove an inflight action by ID
469
+ */
470
+ removeInflightAction(id: string): void {
471
+ navState = {
472
+ ...navState,
473
+ inflightActions: navState.inflightActions.filter((a) => a.id !== id),
474
+ };
475
+ notifyStateListeners();
476
+ },
477
+
478
+ // ========================================================================
479
+ // Action State (for controlling update behavior during server actions)
480
+ // ========================================================================
481
+
482
+ /**
483
+ * Check if a server action is currently in progress
484
+ */
485
+ isActionInProgress(): boolean {
486
+ return actionInProgress;
487
+ },
488
+
489
+ /**
490
+ * Set the action in progress flag
491
+ */
492
+ setActionInProgress(value: boolean): void {
493
+ actionInProgress = value;
494
+ },
495
+
496
+ // ========================================================================
497
+ // Internal Segment State (for bridges)
498
+ // ========================================================================
499
+
500
+ /**
501
+ * Get internal segment state
502
+ */
503
+ getSegmentState(): SegmentState {
504
+ return segmentState;
505
+ },
506
+
507
+ /**
508
+ * Set current path
509
+ */
510
+ setPath(path: string): void {
511
+ segmentState.path = path;
512
+ },
513
+
514
+ /**
515
+ * Set current URL
516
+ */
517
+ setCurrentUrl(url: string): void {
518
+ segmentState.currentUrl = url;
519
+ },
520
+
521
+ /**
522
+ * Set current segment IDs
523
+ */
524
+ setSegmentIds(ids: string[]): void {
525
+ segmentState.currentSegmentIds = ids;
526
+ },
527
+
528
+ // ========================================================================
529
+ // History-based Segment Cache (for back/forward navigation and partial merging)
530
+ // ========================================================================
531
+
532
+ /**
533
+ * Get the current history key
534
+ */
535
+ getHistoryKey(): string {
536
+ return currentHistoryKey;
537
+ },
538
+
539
+ /**
540
+ * Set the current history key (called when navigating to a new entry)
541
+ */
542
+ setHistoryKey(key: string): void {
543
+ currentHistoryKey = key;
544
+ },
545
+
546
+ /**
547
+ * Store segments for a history entry
548
+ * Updates existing entry if key exists, otherwise adds new entry
549
+ * Removes oldest entries (from front) when over configured cacheSize
550
+ * Fresh data is always stored as not stale (stale=false)
551
+ */
552
+ cacheSegmentsForHistory(
553
+ historyKey: string,
554
+ segments: ResolvedSegment[],
555
+ handleData?: HandleData,
556
+ ): void {
557
+ // Shallow clone handleData arrays to avoid reference sharing between cache entries
558
+ // We only clone the structure (objects and arrays), not the data items themselves,
559
+ // since mutations happen at the array level, not on individual data objects
560
+ const clonedHandleData = handleData
561
+ ? cloneHandleData(handleData)
562
+ : undefined;
563
+
564
+ // Check if entry already exists and update it
565
+ const existingIndex = historyCache.findIndex(
566
+ ([key]) => key === historyKey,
567
+ );
568
+ if (existingIndex !== -1) {
569
+ historyCache[existingIndex] = [
570
+ historyKey,
571
+ segments,
572
+ false,
573
+ clonedHandleData,
574
+ ];
575
+ } else {
576
+ // Add new entry at the end (not stale)
577
+ historyCache.push([historyKey, segments, false, clonedHandleData]);
578
+ // Remove oldest entries if over limit
579
+ while (historyCache.length > cacheSize) {
580
+ historyCache.shift();
581
+ }
582
+ }
583
+ },
584
+
585
+ /**
586
+ * Get cached segments for a history entry
587
+ * Returns { segments, stale, handleData } or undefined if not cached
588
+ */
589
+ getCachedSegments(
590
+ historyKey: string,
591
+ ):
592
+ | { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData }
593
+ | undefined {
594
+ const entry = historyCache.find(([key]) => key === historyKey);
595
+ if (!entry) return undefined;
596
+ return { segments: entry[1], stale: entry[2], handleData: entry[3] };
597
+ },
598
+
599
+ /**
600
+ * Check if segments are cached for a history entry
601
+ */
602
+ hasHistoryCache(historyKey: string): boolean {
603
+ return historyCache.some(([key]) => key === historyKey);
604
+ },
605
+
606
+ /**
607
+ * Update only the handleData for an existing cache entry
608
+ * Does nothing if the cache entry doesn't exist
609
+ * This is used to fix stale handleData after async handles processing
610
+ */
611
+ updateCacheHandleData(historyKey: string, handleData: HandleData): void {
612
+ const existingIndex = historyCache.findIndex(
613
+ ([key]) => key === historyKey,
614
+ );
615
+ if (existingIndex !== -1) {
616
+ const entry = historyCache[existingIndex];
617
+ // Shallow clone handleData arrays to avoid reference sharing
618
+ const clonedHandleData = cloneHandleData(handleData);
619
+ historyCache[existingIndex] = [
620
+ entry[0],
621
+ entry[1],
622
+ entry[2],
623
+ clonedHandleData,
624
+ ];
625
+ }
626
+ },
627
+
628
+ /**
629
+ * Mark all cache entries as stale
630
+ * Called after server actions to indicate data may be outdated
631
+ */
632
+ markCacheAsStale(): void {
633
+ markCacheAsStaleInternal();
634
+ },
635
+
636
+ /**
637
+ * Clear the history cache and broadcast to other tabs
638
+ * Use this for hard invalidation when data is definitely stale
639
+ */
640
+ clearHistoryCache(): void {
641
+ clearCacheAndBroadcast();
642
+ },
643
+
644
+ /**
645
+ * Mark cache as stale and broadcast to other tabs
646
+ * Called after server actions - allows SWR pattern for popstate
647
+ */
648
+ markCacheAsStaleAndBroadcast(): void {
649
+ markStaleAndBroadcast();
650
+ },
651
+
652
+ /**
653
+ * Broadcast cache invalidation to other tabs without clearing local cache
654
+ * Used after consolidation fetch where local cache has fresh data
655
+ */
656
+ broadcastCacheInvalidation(): void {
657
+ broadcastInvalidation();
658
+ },
659
+
660
+ /**
661
+ * Set the callback to invoke when cross-tab refresh is triggered
662
+ * Called by navigation bridge during initialization
663
+ */
664
+ setCrossTabRefreshCallback(callback: () => void): void {
665
+ crossTabRefreshCallback = callback;
666
+ },
667
+
668
+ // ========================================================================
669
+ // Intercept Context Tracking
670
+ // ========================================================================
671
+
672
+ /**
673
+ * Get the intercept source URL
674
+ * This is the URL where the intercept was triggered from (e.g., /shop)
675
+ * Used to maintain intercept context during action revalidation
676
+ */
677
+ getInterceptSourceUrl(): string | null {
678
+ return interceptSourceUrl;
679
+ },
680
+
681
+ /**
682
+ * Set the intercept source URL
683
+ * Called when an intercept navigation is detected
684
+ * Set to null when leaving intercept context (e.g., closing modal)
685
+ */
686
+ setInterceptSourceUrl(url: string | null): void {
687
+ interceptSourceUrl = url;
688
+ },
689
+
690
+ // ========================================================================
691
+ // UI Update Notifications
692
+ // ========================================================================
693
+
694
+ /**
695
+ * Subscribe to UI updates (when root needs to re-render)
696
+ */
697
+ onUpdate(callback: UpdateSubscriber): () => void {
698
+ updateSubscribers.add(callback);
699
+ return () => {
700
+ updateSubscribers.delete(callback);
701
+ };
702
+ },
703
+
704
+ /**
705
+ * Emit a UI update to all subscribers
706
+ */
707
+ emitUpdate(update: NavigationUpdate): void {
708
+ updateSubscribers.forEach((callback) => {
709
+ callback(update);
710
+ });
711
+ },
712
+
713
+ // ========================================================================
714
+ // Action State Tracking (for useAction hook)
715
+ // ========================================================================
716
+
717
+ /**
718
+ * Get the current state for a tracked action
719
+ * Returns default idle state if action hasn't been tracked
720
+ */
721
+ getActionState(actionId: string): TrackedActionState {
722
+ return actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
723
+ },
724
+
725
+ /**
726
+ * Update the state for a tracked action
727
+ * Merges partial state with existing state and notifies listeners
728
+ */
729
+ setActionState(
730
+ actionId: string,
731
+ partial: Partial<TrackedActionState>,
732
+ ): void {
733
+ const current = actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
734
+ const updated: TrackedActionState = {
735
+ ...current,
736
+ ...partial,
737
+ actionId, // Always set the actionId
738
+ };
739
+ actionStates.set(actionId, updated);
740
+ notifyActionListeners(actionId, updated);
741
+ },
742
+
743
+ /**
744
+ * Subscribe to state changes for a specific action
745
+ * Returns unsubscribe function
746
+ */
747
+ subscribeToAction(
748
+ actionId: string,
749
+ listener: ActionStateListener,
750
+ ): () => void {
751
+ let listeners = actionListeners.get(actionId);
752
+ if (!listeners) {
753
+ listeners = new Set();
754
+ actionListeners.set(actionId, listeners);
755
+ }
756
+ listeners.add(listener);
757
+
758
+ return () => {
759
+ listeners!.delete(listener);
760
+ // Clean up empty listener sets
761
+ if (listeners!.size === 0) {
762
+ actionListeners.delete(actionId);
763
+ }
764
+ };
765
+ },
766
+ };
767
+ }
768
+
769
+ // Singleton store instance
770
+ let storeInstance: NavigationStore | null = null;
771
+
772
+ /**
773
+ * Initialize the global navigation store
774
+ *
775
+ * Should be called once during app initialization.
776
+ * Subsequent calls return the existing instance.
777
+ */
778
+ export function initNavigationStore(
779
+ config?: NavigationStoreConfig,
780
+ ): NavigationStore {
781
+ if (!storeInstance) {
782
+ storeInstance = createNavigationStore(config);
783
+ }
784
+ return storeInstance;
785
+ }
786
+
787
+ /**
788
+ * Get the global navigation store
789
+ *
790
+ * Throws if store hasn't been initialized.
791
+ */
792
+ export function getNavigationStore(): NavigationStore {
793
+ if (!storeInstance) {
794
+ throw new Error(
795
+ "Navigation store not initialized. Call initNavigationStore first.",
796
+ );
797
+ }
798
+ return storeInstance;
799
+ }
800
+
801
+ /**
802
+ * Reset the store instance (for testing)
803
+ */
804
+ export function resetNavigationStore(): void {
805
+ storeInstance = null;
806
+ }