@rangojs/router 0.0.0-experimental.10 → 0.0.0-experimental.100

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 (329) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +1037 -4
  3. package/dist/bin/rango.js +1619 -157
  4. package/dist/vite/index.js +5762 -2301
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +71 -63
  7. package/skills/breadcrumbs/SKILL.md +252 -0
  8. package/skills/cache-guide/SKILL.md +294 -0
  9. package/skills/caching/SKILL.md +93 -23
  10. package/skills/composability/SKILL.md +172 -0
  11. package/skills/debug-manifest/SKILL.md +12 -8
  12. package/skills/document-cache/SKILL.md +18 -16
  13. package/skills/fonts/SKILL.md +6 -4
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +367 -71
  16. package/skills/host-router/SKILL.md +218 -0
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +176 -8
  19. package/skills/layout/SKILL.md +124 -3
  20. package/skills/links/SKILL.md +304 -25
  21. package/skills/loader/SKILL.md +474 -47
  22. package/skills/middleware/SKILL.md +207 -37
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +15 -11
  26. package/skills/parallel/SKILL.md +272 -1
  27. package/skills/prerender/SKILL.md +467 -65
  28. package/skills/rango/SKILL.md +89 -21
  29. package/skills/response-routes/SKILL.md +152 -91
  30. package/skills/route/SKILL.md +305 -14
  31. package/skills/router-setup/SKILL.md +210 -32
  32. package/skills/server-actions/SKILL.md +739 -0
  33. package/skills/streams-and-websockets/SKILL.md +283 -0
  34. package/skills/theme/SKILL.md +9 -8
  35. package/skills/typesafety/SKILL.md +333 -86
  36. package/skills/use-cache/SKILL.md +324 -0
  37. package/skills/view-transitions/SKILL.md +212 -0
  38. package/src/__internal.ts +102 -4
  39. package/src/bin/rango.ts +312 -15
  40. package/src/browser/action-coordinator.ts +97 -0
  41. package/src/browser/action-response-classifier.ts +99 -0
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/app-version.ts +14 -0
  44. package/src/browser/event-controller.ts +136 -68
  45. package/src/browser/history-state.ts +80 -0
  46. package/src/browser/intercept-utils.ts +52 -0
  47. package/src/browser/link-interceptor.ts +24 -4
  48. package/src/browser/logging.ts +55 -0
  49. package/src/browser/merge-segment-loaders.ts +20 -12
  50. package/src/browser/navigation-bridge.ts +374 -561
  51. package/src/browser/navigation-client.ts +228 -70
  52. package/src/browser/navigation-store.ts +97 -55
  53. package/src/browser/navigation-transaction.ts +297 -0
  54. package/src/browser/network-error-handler.ts +61 -0
  55. package/src/browser/partial-update.ts +376 -315
  56. package/src/browser/prefetch/cache.ts +314 -0
  57. package/src/browser/prefetch/fetch.ts +282 -0
  58. package/src/browser/prefetch/observer.ts +65 -0
  59. package/src/browser/prefetch/policy.ts +48 -0
  60. package/src/browser/prefetch/queue.ts +191 -0
  61. package/src/browser/prefetch/resource-ready.ts +77 -0
  62. package/src/browser/rango-state.ts +152 -0
  63. package/src/browser/react/Link.tsx +255 -71
  64. package/src/browser/react/NavigationProvider.tsx +152 -24
  65. package/src/browser/react/context.ts +11 -0
  66. package/src/browser/react/filter-segment-order.ts +55 -0
  67. package/src/browser/react/index.ts +15 -12
  68. package/src/browser/react/location-state-shared.ts +95 -53
  69. package/src/browser/react/location-state.ts +60 -15
  70. package/src/browser/react/mount-context.ts +6 -1
  71. package/src/browser/react/nonce-context.ts +23 -0
  72. package/src/browser/react/shallow-equal.ts +27 -0
  73. package/src/browser/react/use-action.ts +29 -51
  74. package/src/browser/react/use-client-cache.ts +5 -3
  75. package/src/browser/react/use-handle.ts +30 -120
  76. package/src/browser/react/use-link-status.ts +6 -5
  77. package/src/browser/react/use-navigation.ts +44 -65
  78. package/src/browser/react/use-params.ts +78 -0
  79. package/src/browser/react/use-pathname.ts +47 -0
  80. package/src/browser/react/use-reverse.ts +99 -0
  81. package/src/browser/react/use-router.ts +83 -0
  82. package/src/browser/react/use-search-params.ts +56 -0
  83. package/src/browser/react/use-segments.ts +85 -99
  84. package/src/browser/response-adapter.ts +73 -0
  85. package/src/browser/rsc-router.tsx +246 -64
  86. package/src/browser/scroll-restoration.ts +127 -52
  87. package/src/browser/segment-reconciler.ts +243 -0
  88. package/src/browser/segment-structure-assert.ts +16 -0
  89. package/src/browser/server-action-bridge.ts +510 -603
  90. package/src/browser/shallow.ts +6 -1
  91. package/src/browser/types.ts +158 -48
  92. package/src/browser/validate-redirect-origin.ts +29 -0
  93. package/src/build/generate-manifest.ts +84 -23
  94. package/src/build/generate-route-types.ts +39 -828
  95. package/src/build/index.ts +4 -5
  96. package/src/build/route-trie.ts +85 -32
  97. package/src/build/route-types/ast-helpers.ts +25 -0
  98. package/src/build/route-types/ast-route-extraction.ts +98 -0
  99. package/src/build/route-types/codegen.ts +102 -0
  100. package/src/build/route-types/include-resolution.ts +418 -0
  101. package/src/build/route-types/param-extraction.ts +48 -0
  102. package/src/build/route-types/per-module-writer.ts +128 -0
  103. package/src/build/route-types/router-processing.ts +618 -0
  104. package/src/build/route-types/scan-filter.ts +85 -0
  105. package/src/build/runtime-discovery.ts +231 -0
  106. package/src/cache/background-task.ts +34 -0
  107. package/src/cache/cache-key-utils.ts +44 -0
  108. package/src/cache/cache-policy.ts +125 -0
  109. package/src/cache/cache-runtime.ts +342 -0
  110. package/src/cache/cache-scope.ts +167 -307
  111. package/src/cache/cf/cf-cache-store.ts +573 -21
  112. package/src/cache/cf/index.ts +13 -3
  113. package/src/cache/document-cache.ts +116 -77
  114. package/src/cache/handle-capture.ts +81 -0
  115. package/src/cache/handle-snapshot.ts +41 -0
  116. package/src/cache/index.ts +1 -15
  117. package/src/cache/memory-segment-store.ts +191 -13
  118. package/src/cache/profile-registry.ts +73 -0
  119. package/src/cache/read-through-swr.ts +134 -0
  120. package/src/cache/segment-codec.ts +256 -0
  121. package/src/cache/taint.ts +153 -0
  122. package/src/cache/types.ts +72 -122
  123. package/src/client.rsc.tsx +6 -1
  124. package/src/client.tsx +118 -302
  125. package/src/component-utils.ts +4 -4
  126. package/src/components/DefaultDocument.tsx +5 -1
  127. package/src/context-var.ts +156 -0
  128. package/src/debug.ts +19 -9
  129. package/src/errors.ts +77 -7
  130. package/src/handle.ts +55 -10
  131. package/src/handles/MetaTags.tsx +73 -20
  132. package/src/handles/breadcrumbs.ts +66 -0
  133. package/src/handles/index.ts +1 -0
  134. package/src/handles/meta.ts +30 -13
  135. package/src/host/cookie-handler.ts +21 -15
  136. package/src/host/errors.ts +8 -8
  137. package/src/host/index.ts +4 -7
  138. package/src/host/pattern-matcher.ts +27 -27
  139. package/src/host/router.ts +61 -39
  140. package/src/host/testing.ts +8 -8
  141. package/src/host/types.ts +15 -7
  142. package/src/host/utils.ts +1 -1
  143. package/src/href-client.ts +65 -45
  144. package/src/index.rsc.ts +138 -21
  145. package/src/index.ts +206 -51
  146. package/src/internal-debug.ts +11 -0
  147. package/src/loader.rsc.ts +25 -143
  148. package/src/loader.ts +27 -10
  149. package/src/network-error-thrower.tsx +3 -1
  150. package/src/outlet-context.ts +1 -1
  151. package/src/outlet-provider.tsx +45 -0
  152. package/src/prerender/param-hash.ts +4 -2
  153. package/src/prerender/store.ts +159 -13
  154. package/src/prerender.ts +397 -29
  155. package/src/response-utils.ts +28 -0
  156. package/src/reverse.ts +231 -121
  157. package/src/root-error-boundary.tsx +41 -29
  158. package/src/route-content-wrapper.tsx +7 -4
  159. package/src/route-definition/dsl-helpers.ts +1134 -0
  160. package/src/route-definition/helper-factories.ts +200 -0
  161. package/src/route-definition/helpers-types.ts +483 -0
  162. package/src/route-definition/index.ts +55 -0
  163. package/src/route-definition/redirect.ts +101 -0
  164. package/src/route-definition/resolve-handler-use.ts +155 -0
  165. package/src/route-definition.ts +1 -1431
  166. package/src/route-map-builder.ts +162 -123
  167. package/src/route-name.ts +53 -0
  168. package/src/route-types.ts +66 -9
  169. package/src/router/content-negotiation.ts +215 -0
  170. package/src/router/debug-manifest.ts +72 -0
  171. package/src/router/error-handling.ts +9 -9
  172. package/src/router/find-match.ts +160 -0
  173. package/src/router/handler-context.ts +418 -86
  174. package/src/router/intercept-resolution.ts +35 -20
  175. package/src/router/lazy-includes.ts +237 -0
  176. package/src/router/loader-resolution.ts +359 -128
  177. package/src/router/logging.ts +251 -0
  178. package/src/router/manifest.ts +98 -32
  179. package/src/router/match-api.ts +196 -261
  180. package/src/router/match-context.ts +4 -2
  181. package/src/router/match-handlers.ts +441 -0
  182. package/src/router/match-middleware/background-revalidation.ts +108 -93
  183. package/src/router/match-middleware/cache-lookup.ts +415 -86
  184. package/src/router/match-middleware/cache-store.ts +91 -29
  185. package/src/router/match-middleware/intercept-resolution.ts +48 -21
  186. package/src/router/match-middleware/segment-resolution.ts +73 -9
  187. package/src/router/match-pipelines.ts +10 -45
  188. package/src/router/match-result.ts +154 -35
  189. package/src/router/metrics.ts +240 -15
  190. package/src/router/middleware-cookies.ts +55 -0
  191. package/src/router/middleware-types.ts +209 -0
  192. package/src/router/middleware.ts +373 -371
  193. package/src/router/navigation-snapshot.ts +182 -0
  194. package/src/router/pattern-matching.ts +292 -52
  195. package/src/router/prerender-match.ts +502 -0
  196. package/src/router/preview-match.ts +98 -0
  197. package/src/router/request-classification.ts +310 -0
  198. package/src/router/revalidation.ts +152 -39
  199. package/src/router/route-snapshot.ts +245 -0
  200. package/src/router/router-context.ts +41 -21
  201. package/src/router/router-interfaces.ts +484 -0
  202. package/src/router/router-options.ts +618 -0
  203. package/src/router/router-registry.ts +24 -0
  204. package/src/router/segment-resolution/fresh.ts +756 -0
  205. package/src/router/segment-resolution/helpers.ts +268 -0
  206. package/src/router/segment-resolution/loader-cache.ts +199 -0
  207. package/src/router/segment-resolution/revalidation.ts +1407 -0
  208. package/src/router/segment-resolution/static-store.ts +67 -0
  209. package/src/router/segment-resolution.ts +21 -1315
  210. package/src/router/segment-wrappers.ts +291 -0
  211. package/src/router/substitute-pattern-params.ts +56 -0
  212. package/src/router/telemetry-otel.ts +299 -0
  213. package/src/router/telemetry.ts +300 -0
  214. package/src/router/timeout.ts +148 -0
  215. package/src/router/trie-matching.ts +111 -39
  216. package/src/router/types.ts +17 -9
  217. package/src/router/url-params.ts +49 -0
  218. package/src/router.ts +642 -2011
  219. package/src/rsc/handler-context.ts +45 -0
  220. package/src/rsc/handler.ts +864 -1114
  221. package/src/rsc/helpers.ts +181 -19
  222. package/src/rsc/index.ts +0 -20
  223. package/src/rsc/loader-fetch.ts +229 -0
  224. package/src/rsc/manifest-init.ts +90 -0
  225. package/src/rsc/nonce.ts +14 -0
  226. package/src/rsc/origin-guard.ts +141 -0
  227. package/src/rsc/progressive-enhancement.ts +395 -0
  228. package/src/rsc/response-error.ts +37 -0
  229. package/src/rsc/response-route-handler.ts +360 -0
  230. package/src/rsc/rsc-rendering.ts +256 -0
  231. package/src/rsc/runtime-warnings.ts +42 -0
  232. package/src/rsc/server-action.ts +360 -0
  233. package/src/rsc/ssr-setup.ts +128 -0
  234. package/src/rsc/types.ts +52 -11
  235. package/src/search-params.ts +230 -0
  236. package/src/segment-content-promise.ts +67 -0
  237. package/src/segment-loader-promise.ts +122 -0
  238. package/src/segment-system.tsx +187 -38
  239. package/src/server/context.ts +333 -59
  240. package/src/server/cookie-store.ts +190 -0
  241. package/src/server/fetchable-loader-store.ts +37 -0
  242. package/src/server/handle-store.ts +113 -15
  243. package/src/server/loader-registry.ts +24 -64
  244. package/src/server/request-context.ts +603 -109
  245. package/src/server.ts +35 -155
  246. package/src/ssr/index.tsx +107 -30
  247. package/src/static-handler.ts +126 -0
  248. package/src/theme/ThemeProvider.tsx +21 -15
  249. package/src/theme/ThemeScript.tsx +5 -5
  250. package/src/theme/constants.ts +5 -2
  251. package/src/theme/index.ts +4 -14
  252. package/src/theme/theme-context.ts +4 -30
  253. package/src/theme/theme-script.ts +21 -18
  254. package/src/types/boundaries.ts +158 -0
  255. package/src/types/cache-types.ts +198 -0
  256. package/src/types/error-types.ts +192 -0
  257. package/src/types/global-namespace.ts +100 -0
  258. package/src/types/handler-context.ts +764 -0
  259. package/src/types/index.ts +88 -0
  260. package/src/types/loader-types.ts +209 -0
  261. package/src/types/request-scope.ts +126 -0
  262. package/src/types/route-config.ts +170 -0
  263. package/src/types/route-entry.ts +120 -0
  264. package/src/types/segments.ts +167 -0
  265. package/src/types.ts +1 -1757
  266. package/src/urls/include-helper.ts +207 -0
  267. package/src/urls/index.ts +53 -0
  268. package/src/urls/path-helper-types.ts +372 -0
  269. package/src/urls/path-helper.ts +364 -0
  270. package/src/urls/pattern-types.ts +107 -0
  271. package/src/urls/response-types.ts +108 -0
  272. package/src/urls/type-extraction.ts +372 -0
  273. package/src/urls/urls-function.ts +98 -0
  274. package/src/urls.ts +1 -1282
  275. package/src/use-loader.tsx +161 -81
  276. package/src/vite/debug.ts +184 -0
  277. package/src/vite/discovery/bundle-postprocess.ts +181 -0
  278. package/src/vite/discovery/discover-routers.ts +376 -0
  279. package/src/vite/discovery/gate-state.ts +171 -0
  280. package/src/vite/discovery/prerender-collection.ts +486 -0
  281. package/src/vite/discovery/route-types-writer.ts +258 -0
  282. package/src/vite/discovery/self-gen-tracking.ts +73 -0
  283. package/src/vite/discovery/state.ts +117 -0
  284. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  285. package/src/vite/index.ts +15 -2063
  286. package/src/vite/plugin-types.ts +103 -0
  287. package/src/vite/plugins/cjs-to-esm.ts +98 -0
  288. package/src/vite/plugins/client-ref-dedup.ts +131 -0
  289. package/src/vite/plugins/client-ref-hashing.ts +117 -0
  290. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  291. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  292. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  293. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +107 -64
  294. package/src/vite/plugins/expose-id-utils.ts +299 -0
  295. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  296. package/src/vite/plugins/expose-ids/handler-transform.ts +209 -0
  297. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  298. package/src/vite/plugins/expose-ids/router-transform.ts +127 -0
  299. package/src/vite/plugins/expose-ids/types.ts +45 -0
  300. package/src/vite/plugins/expose-internal-ids.ts +816 -0
  301. package/src/vite/plugins/performance-tracks.ts +96 -0
  302. package/src/vite/plugins/refresh-cmd.ts +127 -0
  303. package/src/vite/plugins/use-cache-transform.ts +336 -0
  304. package/src/vite/plugins/version-injector.ts +109 -0
  305. package/src/vite/plugins/version-plugin.ts +266 -0
  306. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
  307. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  308. package/src/vite/rango.ts +497 -0
  309. package/src/vite/router-discovery.ts +1423 -0
  310. package/src/vite/utils/ast-handler-extract.ts +517 -0
  311. package/src/vite/utils/banner.ts +36 -0
  312. package/src/vite/utils/bundle-analysis.ts +137 -0
  313. package/src/vite/utils/manifest-utils.ts +70 -0
  314. package/src/vite/utils/package-resolution.ts +161 -0
  315. package/src/vite/utils/prerender-utils.ts +222 -0
  316. package/src/vite/utils/shared-utils.ts +170 -0
  317. package/CLAUDE.md +0 -43
  318. package/src/browser/lru-cache.ts +0 -69
  319. package/src/browser/request-controller.ts +0 -164
  320. package/src/cache/memory-store.ts +0 -253
  321. package/src/href-context.ts +0 -33
  322. package/src/router.gen.ts +0 -6
  323. package/src/urls.gen.ts +0 -8
  324. package/src/vite/expose-handle-id.ts +0 -209
  325. package/src/vite/expose-loader-id.ts +0 -426
  326. package/src/vite/expose-location-state-id.ts +0 -177
  327. package/src/vite/expose-prerender-handler-id.ts +0 -429
  328. package/src/vite/package-resolution.ts +0 -125
  329. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -8,7 +8,9 @@ import type {
8
8
  ResolvedSegment,
9
9
  RscMetadata,
10
10
  HandleData,
11
+ StreamingToken,
11
12
  } from "./types.js";
13
+ import { filterSegmentOrder } from "./react/filter-segment-order.js";
12
14
 
13
15
  // Polyfill Symbol.dispose for Safari and older browsers
14
16
  if (typeof Symbol.dispose === "undefined") {
@@ -40,7 +42,7 @@ export interface NavigationEntry {
40
42
  abort: AbortController;
41
43
  phase: NavigationPhase;
42
44
  startedAt: number;
43
- options?: NavigateOptions;
45
+ options?: NavigateOptions & { skipLoadingState?: boolean };
44
46
  }
45
47
 
46
48
  /**
@@ -77,6 +79,8 @@ export interface DerivedNavigationState {
77
79
  state: "idle" | "loading";
78
80
  /** Whether any operation is streaming */
79
81
  isStreaming: boolean;
82
+ /** Whether a navigation is active (fetching or streaming, before commit) */
83
+ isNavigating: boolean;
80
84
  /** Current committed location */
81
85
  location: NavigationLocation;
82
86
  /** URL being navigated to (null if idle) */
@@ -109,20 +113,24 @@ export type ActionStateListener = (state: TrackedActionState) => void;
109
113
  export type HandleListener = () => void;
110
114
 
111
115
  /**
112
- * Internal handle state stored in controller
116
+ * Internal handle state stored in controller.
117
+ *
118
+ * Two segment lists are exposed because they serve different consumers:
119
+ *
120
+ * - `segmentOrder` drives handle collection (collectHandleData). Includes
121
+ * parallel slot ids and reorders them after their parent so later-wins
122
+ * collect functions (e.g. Meta) get the right precedence.
123
+ * - `routeSegmentIds` is the layouts-and-routes-only list documented by
124
+ * `useSegments().segmentIds`. Parallels and loader sub-ids are stripped;
125
+ * raw matched order is preserved.
126
+ *
127
+ * Both are derived from the same `matched` input on each setHandleData call
128
+ * so they stay in sync.
113
129
  */
114
130
  export interface HandleState {
115
131
  data: HandleData;
116
132
  segmentOrder: string[];
117
- }
118
-
119
- /**
120
- * Token for tracking an active stream
121
- * Call end() when the stream completes
122
- */
123
- export interface StreamingToken {
124
- /** End this streaming operation */
125
- end(): void;
133
+ routeSegmentIds: string[];
126
134
  }
127
135
 
128
136
  /**
@@ -165,8 +173,8 @@ export interface ActionHandle extends Disposable {
165
173
  readonly settled: boolean;
166
174
  /** Check if any concurrent actions were started */
167
175
  hadConcurrentActions: boolean;
168
- /** Get segments to consolidate (only valid when this is the last action) */
169
- getConsolidationSegments(): string[] | null;
176
+ /** Get raw set of segments revalidated by concurrent actions */
177
+ getRevalidatedSegments(): Set<string>;
170
178
  /** Clear consolidation tracking */
171
179
  clearConsolidation(): void;
172
180
  }
@@ -176,7 +184,10 @@ export interface ActionHandle extends Disposable {
176
184
  */
177
185
  export interface EventController {
178
186
  // Navigation operations
179
- startNavigation(url: string, options?: NavigateOptions): NavigationHandle;
187
+ startNavigation(
188
+ url: string,
189
+ options?: NavigateOptions & { skipLoadingState?: boolean },
190
+ ): NavigationHandle;
180
191
  abortNavigation(): void;
181
192
 
182
193
  // Action operations
@@ -186,6 +197,7 @@ export interface EventController {
186
197
  // State access
187
198
  getState(): DerivedNavigationState;
188
199
  getActionState(actionId: string): TrackedActionState;
200
+ getLocation(): NavigationLocation;
189
201
 
190
202
  // Location updates (for popstate where navigation doesn't go through startNavigation)
191
203
  setLocation(location: NavigationLocation): void;
@@ -194,7 +206,7 @@ export interface EventController {
194
206
  subscribe(listener: StateListener): () => void;
195
207
  subscribeToAction(
196
208
  actionId: string,
197
- listener: ActionStateListener
209
+ listener: ActionStateListener,
198
210
  ): () => void;
199
211
  subscribeToHandles(listener: HandleListener): () => void;
200
212
 
@@ -202,13 +214,27 @@ export interface EventController {
202
214
  setHandleData(
203
215
  data: HandleData,
204
216
  matched?: string[],
205
- isPartial?: boolean
217
+ isPartial?: boolean,
218
+ /**
219
+ * Segment ids that were re-resolved on the server this request (the
220
+ * partial response's `diff`). On a partial update, any existing bucket
221
+ * keyed under one of these ids that has no incoming entry is treated as
222
+ * stale and cleared. Without this, a parallel slot that revalidates but
223
+ * pushes nothing leaves its previous bucket in place forever.
224
+ */
225
+ resolvedIds?: string[],
206
226
  ): void;
207
227
  getHandleState(): HandleState;
208
228
 
229
+ // Params operations
230
+ setParams(params: Record<string, string>): void;
231
+ getParams(): Record<string, string>;
232
+
209
233
  // Direct state access for advanced use
210
234
  getCurrentNavigation(): NavigationEntry | null;
211
235
  getInflightActions(): Map<string, ActionEntry>;
236
+ /** Whether any concurrent actions have occurred (shared across all handles) */
237
+ hadAnyConcurrentActions(): boolean;
212
238
  }
213
239
 
214
240
  // ============================================================================
@@ -230,7 +256,10 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
230
256
  * When subscriptionId has no '#', it's just an action name and matches by suffix.
231
257
  * This allows useAction("addToCart") to match "hash#addToCart" or "src/file.ts#addToCart".
232
258
  */
233
- function matchesActionId(subscriptionId: string, entryActionId: string): boolean {
259
+ function matchesActionId(
260
+ subscriptionId: string,
261
+ entryActionId: string,
262
+ ): boolean {
234
263
  if (subscriptionId.includes("#")) {
235
264
  // Full ID: exact match
236
265
  return subscriptionId === entryActionId;
@@ -261,7 +290,7 @@ export interface EventControllerConfig {
261
290
  * Actions use mergeMap semantics (all run concurrently, consolidate at end).
262
291
  */
263
292
  export function createEventController(
264
- config?: EventControllerConfig
293
+ config?: EventControllerConfig,
265
294
  ): EventController {
266
295
  // ========================================================================
267
296
  // Source of Truth
@@ -292,6 +321,10 @@ export function createEventController(
292
321
  // Handle data from RSC payload
293
322
  let handleData: HandleData = {};
294
323
  let handleSegmentOrder: string[] = [];
324
+ let routeSegmentIds: string[] = [];
325
+
326
+ // Merged route params from current match
327
+ let routeParams: Record<string, string> = {};
295
328
 
296
329
  // ========================================================================
297
330
  // Listeners
@@ -334,7 +367,7 @@ export function createEventController(
334
367
  listeners.forEach((listener) => listener(state));
335
368
  }
336
369
  }
337
- }, 0)
370
+ }, 0),
338
371
  );
339
372
  }
340
373
 
@@ -367,9 +400,12 @@ export function createEventController(
367
400
  }));
368
401
 
369
402
  // State: loading if navigation OR actions are in progress
403
+ // Background revalidations (skipLoadingState) don't affect visible state
370
404
  const hasActiveActions = inflightActionsList.length > 0;
371
- const state =
372
- currentNavigation !== null || hasActiveActions ? "loading" : "idle";
405
+ const isVisibleNavigation =
406
+ currentNavigation !== null &&
407
+ !currentNavigation.options?.skipLoadingState;
408
+ const state = isVisibleNavigation || hasActiveActions ? "loading" : "idle";
373
409
 
374
410
  // Streaming: true if any active streams (navigation or action) or loading
375
411
  const isStreaming = activeStreamCount > 0 || state === "loading";
@@ -377,9 +413,17 @@ export function createEventController(
377
413
  return {
378
414
  state,
379
415
  isStreaming,
416
+ // True when a navigation is active (fetching or streaming, before
417
+ // commit). Broader than pendingUrl which clears during streaming.
418
+ isNavigating: currentNavigation !== null,
380
419
  location,
381
- // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending
382
- pendingUrl: currentNavigation?.phase === "fetching" ? currentNavigation.url : null,
420
+ // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
421
+ // Background revalidations (skipLoadingState) don't expose a pending URL.
422
+ pendingUrl:
423
+ currentNavigation?.phase === "fetching" &&
424
+ !currentNavigation.options?.skipLoadingState
425
+ ? currentNavigation.url
426
+ : null,
383
427
  inflightActions: inflightActionsList,
384
428
  };
385
429
  }
@@ -388,12 +432,16 @@ export function createEventController(
388
432
  // Find the most recent action with this ID that's not settling
389
433
  // Uses suffix matching when actionId is just a name (no #)
390
434
  const activeEntry = [...inflightActions.values()]
391
- .filter((a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling")
435
+ .filter(
436
+ (a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling",
437
+ )
392
438
  .sort((a, b) => b.startedAt - a.startedAt)[0];
393
439
 
394
440
  // Also check for settling entries to get result/error
395
441
  const settlingEntry = [...inflightActions.values()]
396
- .filter((a) => matchesActionId(actionId, a.actionId) && a.phase === "settling")
442
+ .filter(
443
+ (a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
444
+ )
397
445
  .sort((a, b) => b.startedAt - a.startedAt)[0];
398
446
 
399
447
  const entry = activeEntry || settlingEntry;
@@ -431,7 +479,7 @@ export function createEventController(
431
479
 
432
480
  function startNavigation(
433
481
  url: string,
434
- options?: NavigateOptions
482
+ options?: NavigateOptions & { skipLoadingState?: boolean },
435
483
  ): NavigationHandle {
436
484
  // Cancel existing navigation (switchMap semantics)
437
485
  if (currentNavigation) {
@@ -463,6 +511,7 @@ export function createEventController(
463
511
 
464
512
  startStreaming(): StreamingToken {
465
513
  let ended = false;
514
+ entry.phase = "streaming";
466
515
  activeStreamCount++;
467
516
  notify();
468
517
 
@@ -650,24 +699,8 @@ export function createEventController(
650
699
  // If streaming is in progress, tryFinalize() will be called when streaming ends
651
700
  },
652
701
 
653
- getConsolidationSegments(): string[] | null {
654
- // Only consolidate if all actions have at least received their response
655
- // We don't need to wait for streaming to complete since we're refetching anyway
656
- // Count actions that are still fetching (waiting for server response)
657
- const stillFetchingCount = [...inflightActions.values()].filter(
658
- (a) => a.phase === "fetching"
659
- ).length;
660
-
661
- if (stillFetchingCount > 0) {
662
- return null; // Some actions still waiting for server response
663
- }
664
- if (!hadAnyConcurrentActions) {
665
- return null; // No concurrent actions occurred
666
- }
667
- if (concurrentRevalidatedSegments.size === 0) {
668
- return null; // No segments to consolidate
669
- }
670
- return Array.from(concurrentRevalidatedSegments);
702
+ getRevalidatedSegments(): Set<string> {
703
+ return concurrentRevalidatedSegments;
671
704
  },
672
705
 
673
706
  clearConsolidation() {
@@ -702,16 +735,26 @@ export function createEventController(
702
735
  }
703
736
 
704
737
  function abortAllActions() {
705
- for (const entry of inflightActions.values()) {
738
+ for (const [id, entry] of inflightActions) {
739
+ // Preserve settling entries — they have already been handled by
740
+ // fail()/complete() and will self-cleanup via the settlement timeout.
741
+ // Clearing them here would prevent debounced notifications from
742
+ // delivering the error/result state to subscribers.
743
+ if (entry.phase === "settling") continue;
706
744
  entry.abort.abort();
745
+ inflightActions.delete(id);
707
746
  }
708
- inflightActions.clear();
709
747
  hadAnyConcurrentActions = false;
710
748
  concurrentRevalidatedSegments.clear();
711
749
  notify();
712
- // Notify all action listeners
713
- for (const actionId of actionListeners.keys()) {
714
- notifyAction(actionId);
750
+ // Notify all action listeners directly by subscription ID.
751
+ // actionListeners keys are subscription IDs (possibly short names like
752
+ // "addToCart"), not full entry actionIds. Passing them to notifyAction
753
+ // would fail the suffix matcher — instead, notify each subscriber with
754
+ // its own state.
755
+ for (const [subscriptionId, listeners] of actionListeners) {
756
+ const state = getActionState(subscriptionId);
757
+ listeners.forEach((listener) => listener(state));
715
758
  }
716
759
  }
717
760
 
@@ -719,24 +762,19 @@ export function createEventController(
719
762
  // Handle Operations
720
763
  // ========================================================================
721
764
 
722
- /**
723
- * Filter segment IDs to only include routes and layouts.
724
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
725
- */
726
- function filterSegmentOrder(matched: string[]): string[] {
727
- return matched.filter((id) => {
728
- if (id.includes(".@")) return false;
729
- if (/D\d+\./.test(id)) return false;
730
- return true;
731
- });
732
- }
733
-
734
765
  function setHandleData(
735
766
  data: HandleData,
736
767
  matched?: string[],
737
- isPartial?: boolean
768
+ isPartial?: boolean,
769
+ resolvedIds?: string[],
738
770
  ): void {
739
- const newSegmentOrder = filterSegmentOrder(matched ?? []);
771
+ const rawMatched = matched ?? [];
772
+ const newSegmentOrder = filterSegmentOrder(rawMatched);
773
+ // Separate list for useSegments(): "layouts and routes only" — strip
774
+ // parallels (".@") and loader sub-ids (D digit) without reordering.
775
+ const newRouteSegmentIds = rawMatched.filter(
776
+ (id) => !id.includes(".@") && !/D\d+\./.test(id),
777
+ );
740
778
 
741
779
  if (isPartial && newSegmentOrder.length > 0) {
742
780
  // Partial update: merge new data with existing
@@ -748,10 +786,19 @@ export function createEventController(
748
786
  handleData[handleName][segmentId] = data[handleName][segmentId];
749
787
  }
750
788
  }
751
- // Clean up data from segments no longer in the matched list
789
+ const resolvedIdSet =
790
+ resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
791
+ // Cleanup pass:
792
+ // a) segment dropped from the match list — delete its bucket.
793
+ // b) segment was re-resolved this request but pushed nothing for
794
+ // this handle — its previous bucket is stale.
795
+ // (a) is the existing behavior; (b) requires resolvedIds.
752
796
  for (const handleName of Object.keys(handleData)) {
753
797
  for (const segmentId of Object.keys(handleData[handleName])) {
754
- if (!newSegmentOrder.includes(segmentId)) {
798
+ const droppedFromMatch = !newSegmentOrder.includes(segmentId);
799
+ const reresolvedWithoutPush =
800
+ resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
801
+ if (droppedFromMatch || reresolvedWithoutPush) {
755
802
  delete handleData[handleName][segmentId];
756
803
  }
757
804
  }
@@ -761,6 +808,7 @@ export function createEventController(
761
808
  handleData = data;
762
809
  }
763
810
  handleSegmentOrder = newSegmentOrder;
811
+ routeSegmentIds = newRouteSegmentIds;
764
812
 
765
813
  notifyHandles();
766
814
  }
@@ -769,6 +817,7 @@ export function createEventController(
769
817
  return {
770
818
  data: handleData,
771
819
  segmentOrder: handleSegmentOrder,
820
+ routeSegmentIds,
772
821
  };
773
822
  }
774
823
 
@@ -783,7 +832,7 @@ export function createEventController(
783
832
 
784
833
  function subscribeToAction(
785
834
  actionId: string,
786
- listener: ActionStateListener
835
+ listener: ActionStateListener,
787
836
  ): () => void {
788
837
  let listeners = actionListeners.get(actionId);
789
838
  if (!listeners) {
@@ -805,6 +854,19 @@ export function createEventController(
805
854
  return () => handleListeners.delete(listener);
806
855
  }
807
856
 
857
+ // ========================================================================
858
+ // Params Operations
859
+ // ========================================================================
860
+
861
+ function setParams(params: Record<string, string>): void {
862
+ routeParams = params;
863
+ notify();
864
+ }
865
+
866
+ function getParams(): Record<string, string> {
867
+ return routeParams;
868
+ }
869
+
808
870
  // ========================================================================
809
871
  // Return Controller
810
872
  // ========================================================================
@@ -821,12 +883,17 @@ export function createEventController(
821
883
  // State
822
884
  getState,
823
885
  getActionState,
886
+ getLocation: () => location,
824
887
  setLocation,
825
888
 
826
889
  // Handles
827
890
  setHandleData,
828
891
  getHandleState,
829
892
 
893
+ // Params
894
+ setParams,
895
+ getParams,
896
+
830
897
  // Subscriptions
831
898
  subscribe,
832
899
  subscribeToAction,
@@ -835,6 +902,7 @@ export function createEventController(
835
902
  // Direct access
836
903
  getCurrentNavigation: () => currentNavigation,
837
904
  getInflightActions: () => inflightActions,
905
+ hadAnyConcurrentActions: () => hadAnyConcurrentActions,
838
906
  };
839
907
  }
840
908
 
@@ -848,7 +916,7 @@ let controllerInstance: EventController | null = null;
848
916
  * Initialize the global event controller
849
917
  */
850
918
  export function initEventController(
851
- config?: EventControllerConfig
919
+ config?: EventControllerConfig,
852
920
  ): EventController {
853
921
  if (!controllerInstance) {
854
922
  controllerInstance = createEventController(config);
@@ -862,7 +930,7 @@ export function initEventController(
862
930
  export function getEventController(): EventController {
863
931
  if (!controllerInstance) {
864
932
  throw new Error(
865
- "Event controller not initialized. Call initEventController first."
933
+ "Event controller not initialized. Call initEventController first.",
866
934
  );
867
935
  }
868
936
  return controllerInstance;
@@ -0,0 +1,80 @@
1
+ import {
2
+ isLocationStateEntry,
3
+ resolveLocationStateEntries,
4
+ } from "./react/location-state-shared.js";
5
+
6
+ /**
7
+ * Check if state is from typed LocationStateEntry[] (has __rsc_ls_ keys)
8
+ */
9
+ function isTypedLocationState(
10
+ state: unknown,
11
+ ): state is Record<string, unknown> {
12
+ if (state === null || typeof state !== "object") return false;
13
+ return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
14
+ }
15
+
16
+ /**
17
+ * Resolve navigation state - handles both LocationStateEntry[] and plain formats
18
+ */
19
+ export function resolveNavigationState(state: unknown): unknown {
20
+ if (
21
+ Array.isArray(state) &&
22
+ state.length > 0 &&
23
+ isLocationStateEntry(state[0])
24
+ ) {
25
+ return resolveLocationStateEntries(state);
26
+ }
27
+ return state;
28
+ }
29
+
30
+ /**
31
+ * Build history state object from user state
32
+ * - Typed state: spread directly into history.state
33
+ * - Plain state: store in history.state.state
34
+ */
35
+ export function buildHistoryState(
36
+ userState: unknown,
37
+ routerState?: { intercept?: boolean; sourceUrl?: string },
38
+ serverState?: Record<string, unknown>,
39
+ ): Record<string, unknown> | null {
40
+ const result: Record<string, unknown> = {};
41
+
42
+ if (routerState?.intercept) {
43
+ result.intercept = true;
44
+ if (routerState.sourceUrl) {
45
+ result.sourceUrl = routerState.sourceUrl;
46
+ }
47
+ }
48
+
49
+ if (userState !== undefined) {
50
+ if (isTypedLocationState(userState)) {
51
+ Object.assign(result, userState);
52
+ } else {
53
+ result.state = userState;
54
+ }
55
+ }
56
+
57
+ if (serverState) {
58
+ Object.assign(result, serverState);
59
+ }
60
+
61
+ return Object.keys(result).length > 0 ? result : null;
62
+ }
63
+
64
+ /**
65
+ * Merge server-set location state into the current history entry.
66
+ * Replaces the current history state and dispatches notification event
67
+ * so useLocationState hooks re-read from history.state.
68
+ */
69
+ export function mergeLocationState(
70
+ locationState: Record<string, unknown>,
71
+ ): void {
72
+ const merged = {
73
+ ...window.history.state,
74
+ ...locationState,
75
+ };
76
+ window.history.replaceState(merged, "", window.location.href);
77
+ if (Object.keys(locationState).some((k) => k.startsWith("__rsc_ls_"))) {
78
+ window.dispatchEvent(new Event("__rsc_locationstate"));
79
+ }
80
+ }
@@ -0,0 +1,52 @@
1
+ import type { ResolvedSegment } from "./types.js";
2
+ import type { SlotState } from "../types.js";
3
+
4
+ /**
5
+ * Check if a segment is an intercept segment.
6
+ * Intercept segments have namespace starting with "intercept:" — both the
7
+ * parallel container (@modal) and its content children receive this namespace
8
+ * from intercept-resolution.ts. Regular parallel segments like @sidebar do not.
9
+ */
10
+ export function isInterceptSegment(s: ResolvedSegment): boolean {
11
+ return s.namespace?.startsWith("intercept:") === true;
12
+ }
13
+
14
+ /**
15
+ * Split an array of segments into main and intercept groups.
16
+ * Intercept segments are separated for explicit injection into the render tree
17
+ * via the interceptSegments render option.
18
+ */
19
+ export function splitInterceptSegments(segments: ResolvedSegment[]): {
20
+ main: ResolvedSegment[];
21
+ intercept: ResolvedSegment[];
22
+ } {
23
+ const main: ResolvedSegment[] = [];
24
+ const intercept: ResolvedSegment[] = [];
25
+ for (const s of segments) {
26
+ if (isInterceptSegment(s)) {
27
+ intercept.push(s);
28
+ } else {
29
+ main.push(s);
30
+ }
31
+ }
32
+ return { main, intercept };
33
+ }
34
+
35
+ /**
36
+ * Check if any slot is currently active (has content to render).
37
+ * Active slots indicate an intercept response where a parallel segment
38
+ * (e.g., @modal) has matched and should be rendered.
39
+ */
40
+ export function hasActiveIntercept(slots?: Record<string, SlotState>): boolean {
41
+ if (!slots) return false;
42
+ return Object.values(slots).some((slot) => slot.active);
43
+ }
44
+
45
+ /**
46
+ * Check if cached segments contain any intercept segments.
47
+ * Intercept caches shouldn't be used for cached SWR rendering since
48
+ * whether interception happens depends on the current page context.
49
+ */
50
+ export function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
51
+ return segments.some(isInterceptSegment);
52
+ }
@@ -1,5 +1,18 @@
1
1
  import type { LinkInterceptorOptions, NavigateOptions } from "./types.js";
2
2
 
3
+ /**
4
+ * Check if an anchor points to the same page with only a hash change.
5
+ * Used by both Link component and link-interceptor to let the browser
6
+ * handle anchor scrolling natively.
7
+ */
8
+ export function isHashOnlyNavigation(anchor: HTMLAnchorElement): boolean {
9
+ return (
10
+ anchor.pathname === window.location.pathname &&
11
+ anchor.search === window.location.search &&
12
+ !!anchor.hash
13
+ );
14
+ }
15
+
3
16
  /**
4
17
  * Default link interception predicate
5
18
  *
@@ -44,6 +57,12 @@ export function defaultShouldIntercept(link: HTMLAnchorElement): boolean {
44
57
  return false;
45
58
  }
46
59
 
60
+ // Don't intercept hash-only navigation (same path, only fragment changes).
61
+ // Let the browser handle anchor scrolling natively.
62
+ if (isHashOnlyNavigation(link)) {
63
+ return false;
64
+ }
65
+
47
66
  return true;
48
67
  }
49
68
 
@@ -70,7 +89,7 @@ export function defaultShouldIntercept(link: HTMLAnchorElement): boolean {
70
89
  */
71
90
  export function setupLinkInterception(
72
91
  onNavigate: (url: string, options?: NavigateOptions) => void,
73
- options?: LinkInterceptorOptions
92
+ options?: LinkInterceptorOptions,
74
93
  ): () => void {
75
94
  const shouldIntercept = options?.shouldIntercept ?? defaultShouldIntercept;
76
95
 
@@ -98,6 +117,7 @@ export function setupLinkInterception(
98
117
  // Read navigation options from data attributes (set by Link component)
99
118
  const scrollAttr = link.getAttribute("data-scroll");
100
119
  const replaceAttr = link.getAttribute("data-replace");
120
+ const revalidateAttr = link.getAttribute("data-revalidate");
101
121
 
102
122
  const navigateOptions: NavigateOptions = {};
103
123
  if (scrollAttr === "false") {
@@ -106,16 +126,16 @@ export function setupLinkInterception(
106
126
  if (replaceAttr === "true") {
107
127
  navigateOptions.replace = true;
108
128
  }
129
+ if (revalidateAttr === "false") {
130
+ navigateOptions.revalidate = false;
131
+ }
109
132
 
110
133
  onNavigate(href, navigateOptions);
111
134
  };
112
135
 
113
136
  document.addEventListener("click", handleClick);
114
137
 
115
- console.log("[Browser] Link interception enabled");
116
-
117
138
  return () => {
118
139
  document.removeEventListener("click", handleClick);
119
- console.log("[Browser] Link interception disabled");
120
140
  };
121
141
  }
@@ -0,0 +1,55 @@
1
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
+
3
+ interface BrowserLogContext {
4
+ requestId: string;
5
+ txId: string;
6
+ operation: string;
7
+ }
8
+
9
+ let txCounter = 0;
10
+ let requestCounter = 0;
11
+
12
+ export function isBrowserDebugEnabled(): boolean {
13
+ return INTERNAL_RANGO_DEBUG;
14
+ }
15
+
16
+ function nextId(prefix: string, counter: number): string {
17
+ return `${prefix}${counter.toString(36)}`;
18
+ }
19
+
20
+ export function startBrowserTransaction(operation: string): BrowserLogContext {
21
+ txCounter += 1;
22
+ requestCounter += 1;
23
+ return {
24
+ operation,
25
+ txId: nextId("ctx-", txCounter),
26
+ requestId: nextId("creq-", requestCounter),
27
+ };
28
+ }
29
+
30
+ export function browserDebugLog(
31
+ ctx: BrowserLogContext,
32
+ message: string,
33
+ details?: Record<string, unknown>,
34
+ ): void {
35
+ if (!INTERNAL_RANGO_DEBUG) return;
36
+
37
+ const prefix = `[Browser][req:${ctx.requestId}][tx:${ctx.operation}-${ctx.txId}]`;
38
+ if (details) {
39
+ console.log(`${prefix} ${message}`, details);
40
+ return;
41
+ }
42
+
43
+ console.log(`${prefix} ${message}`);
44
+ }
45
+
46
+ /**
47
+ * Simple gated console.log for browser-side debug output.
48
+ * Unlike browserDebugLog, this doesn't require a transaction context -
49
+ * use it for standalone debug messages in partial-update, navigation-bridge, etc.
50
+ */
51
+ export function debugLog(msg: string, ...args: unknown[]): void {
52
+ if (INTERNAL_RANGO_DEBUG) {
53
+ console.log(msg, ...args);
54
+ }
55
+ }