@rangojs/router 0.0.0-experimental.8 → 0.0.0-experimental.8a4d0430

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 (300) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +884 -4
  3. package/dist/bin/rango.js +1601 -0
  4. package/dist/vite/index.js +4474 -867
  5. package/package.json +60 -51
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +262 -0
  8. package/skills/caching/SKILL.md +50 -21
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +12 -8
  11. package/skills/document-cache/SKILL.md +18 -16
  12. package/skills/fonts/SKILL.md +167 -0
  13. package/skills/hooks/SKILL.md +334 -72
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +131 -8
  16. package/skills/layout/SKILL.md +100 -3
  17. package/skills/links/SKILL.md +89 -30
  18. package/skills/loader/SKILL.md +388 -38
  19. package/skills/middleware/SKILL.md +171 -34
  20. package/skills/mime-routes/SKILL.md +128 -0
  21. package/skills/parallel/SKILL.md +78 -1
  22. package/skills/prerender/SKILL.md +643 -0
  23. package/skills/rango/SKILL.md +85 -16
  24. package/skills/response-routes/SKILL.md +411 -0
  25. package/skills/route/SKILL.md +226 -14
  26. package/skills/router-setup/SKILL.md +123 -30
  27. package/skills/tailwind/SKILL.md +129 -0
  28. package/skills/theme/SKILL.md +9 -8
  29. package/skills/typesafety/SKILL.md +318 -89
  30. package/skills/use-cache/SKILL.md +324 -0
  31. package/src/__internal.ts +102 -4
  32. package/src/bin/rango.ts +321 -0
  33. package/src/browser/action-coordinator.ts +97 -0
  34. package/src/browser/action-response-classifier.ts +99 -0
  35. package/src/browser/event-controller.ts +87 -64
  36. package/src/browser/history-state.ts +80 -0
  37. package/src/browser/intercept-utils.ts +52 -0
  38. package/src/browser/link-interceptor.ts +24 -4
  39. package/src/browser/logging.ts +55 -0
  40. package/src/browser/merge-segment-loaders.ts +20 -12
  41. package/src/browser/navigation-bridge.ts +285 -553
  42. package/src/browser/navigation-client.ts +124 -71
  43. package/src/browser/navigation-store.ts +33 -50
  44. package/src/browser/navigation-transaction.ts +295 -0
  45. package/src/browser/network-error-handler.ts +61 -0
  46. package/src/browser/partial-update.ts +258 -308
  47. package/src/browser/prefetch/cache.ts +146 -0
  48. package/src/browser/prefetch/fetch.ts +135 -0
  49. package/src/browser/prefetch/observer.ts +65 -0
  50. package/src/browser/prefetch/policy.ts +42 -0
  51. package/src/browser/prefetch/queue.ts +88 -0
  52. package/src/browser/rango-state.ts +112 -0
  53. package/src/browser/react/Link.tsx +185 -73
  54. package/src/browser/react/NavigationProvider.tsx +51 -11
  55. package/src/browser/react/context.ts +6 -0
  56. package/src/browser/react/filter-segment-order.ts +11 -0
  57. package/src/browser/react/index.ts +12 -12
  58. package/src/browser/react/location-state-shared.ts +95 -53
  59. package/src/browser/react/location-state.ts +60 -15
  60. package/src/browser/react/mount-context.ts +6 -1
  61. package/src/browser/react/nonce-context.ts +23 -0
  62. package/src/browser/react/shallow-equal.ts +27 -0
  63. package/src/browser/react/use-action.ts +29 -51
  64. package/src/browser/react/use-client-cache.ts +5 -3
  65. package/src/browser/react/use-handle.ts +32 -79
  66. package/src/browser/react/use-href.tsx +2 -2
  67. package/src/browser/react/use-link-status.ts +6 -5
  68. package/src/browser/react/use-navigation.ts +22 -63
  69. package/src/browser/react/use-params.ts +65 -0
  70. package/src/browser/react/use-pathname.ts +47 -0
  71. package/src/browser/react/use-router.ts +63 -0
  72. package/src/browser/react/use-search-params.ts +56 -0
  73. package/src/browser/react/use-segments.ts +80 -97
  74. package/src/browser/response-adapter.ts +73 -0
  75. package/src/browser/rsc-router.tsx +107 -26
  76. package/src/browser/scroll-restoration.ts +92 -16
  77. package/src/browser/segment-reconciler.ts +216 -0
  78. package/src/browser/segment-structure-assert.ts +16 -0
  79. package/src/browser/server-action-bridge.ts +504 -599
  80. package/src/browser/shallow.ts +6 -1
  81. package/src/browser/types.ts +109 -47
  82. package/src/browser/validate-redirect-origin.ts +29 -0
  83. package/src/build/generate-manifest.ts +235 -24
  84. package/src/build/generate-route-types.ts +36 -0
  85. package/src/build/index.ts +13 -0
  86. package/src/build/route-trie.ts +265 -0
  87. package/src/build/route-types/ast-helpers.ts +25 -0
  88. package/src/build/route-types/ast-route-extraction.ts +98 -0
  89. package/src/build/route-types/codegen.ts +102 -0
  90. package/src/build/route-types/include-resolution.ts +411 -0
  91. package/src/build/route-types/param-extraction.ts +48 -0
  92. package/src/build/route-types/per-module-writer.ts +128 -0
  93. package/src/build/route-types/router-processing.ts +469 -0
  94. package/src/build/route-types/scan-filter.ts +78 -0
  95. package/src/build/runtime-discovery.ts +231 -0
  96. package/src/cache/background-task.ts +34 -0
  97. package/src/cache/cache-key-utils.ts +44 -0
  98. package/src/cache/cache-policy.ts +125 -0
  99. package/src/cache/cache-runtime.ts +338 -0
  100. package/src/cache/cache-scope.ts +120 -303
  101. package/src/cache/cf/cf-cache-store.ts +119 -7
  102. package/src/cache/cf/index.ts +8 -2
  103. package/src/cache/document-cache.ts +101 -72
  104. package/src/cache/handle-capture.ts +81 -0
  105. package/src/cache/handle-snapshot.ts +41 -0
  106. package/src/cache/index.ts +0 -15
  107. package/src/cache/memory-segment-store.ts +191 -13
  108. package/src/cache/profile-registry.ts +73 -0
  109. package/src/cache/read-through-swr.ts +134 -0
  110. package/src/cache/segment-codec.ts +256 -0
  111. package/src/cache/taint.ts +98 -0
  112. package/src/cache/types.ts +72 -122
  113. package/src/client.rsc.tsx +3 -1
  114. package/src/client.tsx +106 -126
  115. package/src/component-utils.ts +4 -4
  116. package/src/components/DefaultDocument.tsx +5 -1
  117. package/src/context-var.ts +86 -0
  118. package/src/debug.ts +17 -7
  119. package/src/errors.ts +108 -2
  120. package/src/handle.ts +15 -29
  121. package/src/handles/MetaTags.tsx +73 -20
  122. package/src/handles/breadcrumbs.ts +66 -0
  123. package/src/handles/index.ts +1 -0
  124. package/src/handles/meta.ts +30 -13
  125. package/src/host/cookie-handler.ts +21 -15
  126. package/src/host/errors.ts +8 -8
  127. package/src/host/index.ts +4 -7
  128. package/src/host/pattern-matcher.ts +27 -27
  129. package/src/host/router.ts +61 -39
  130. package/src/host/testing.ts +8 -8
  131. package/src/host/types.ts +15 -7
  132. package/src/host/utils.ts +1 -1
  133. package/src/href-client.ts +119 -29
  134. package/src/index.rsc.ts +153 -19
  135. package/src/index.ts +211 -30
  136. package/src/internal-debug.ts +11 -0
  137. package/src/loader.rsc.ts +26 -157
  138. package/src/loader.ts +27 -10
  139. package/src/network-error-thrower.tsx +3 -1
  140. package/src/outlet-provider.tsx +45 -0
  141. package/src/prerender/param-hash.ts +37 -0
  142. package/src/prerender/store.ts +185 -0
  143. package/src/prerender.ts +463 -0
  144. package/src/reverse.ts +330 -0
  145. package/src/root-error-boundary.tsx +41 -29
  146. package/src/route-content-wrapper.tsx +7 -4
  147. package/src/route-definition/dsl-helpers.ts +934 -0
  148. package/src/route-definition/helper-factories.ts +200 -0
  149. package/src/route-definition/helpers-types.ts +430 -0
  150. package/src/route-definition/index.ts +52 -0
  151. package/src/route-definition/redirect.ts +93 -0
  152. package/src/route-definition.ts +1 -1428
  153. package/src/route-map-builder.ts +211 -123
  154. package/src/route-name.ts +53 -0
  155. package/src/route-types.ts +59 -8
  156. package/src/router/content-negotiation.ts +116 -0
  157. package/src/router/debug-manifest.ts +72 -0
  158. package/src/router/error-handling.ts +9 -9
  159. package/src/router/find-match.ts +158 -0
  160. package/src/router/handler-context.ts +374 -81
  161. package/src/router/intercept-resolution.ts +395 -0
  162. package/src/router/lazy-includes.ts +234 -0
  163. package/src/router/loader-resolution.ts +215 -122
  164. package/src/router/logging.ts +248 -0
  165. package/src/router/manifest.ts +148 -35
  166. package/src/router/match-api.ts +620 -0
  167. package/src/router/match-context.ts +5 -3
  168. package/src/router/match-handlers.ts +440 -0
  169. package/src/router/match-middleware/background-revalidation.ts +80 -93
  170. package/src/router/match-middleware/cache-lookup.ts +382 -9
  171. package/src/router/match-middleware/cache-store.ts +51 -22
  172. package/src/router/match-middleware/intercept-resolution.ts +55 -17
  173. package/src/router/match-middleware/segment-resolution.ts +24 -6
  174. package/src/router/match-pipelines.ts +10 -45
  175. package/src/router/match-result.ts +34 -28
  176. package/src/router/metrics.ts +235 -15
  177. package/src/router/middleware-cookies.ts +55 -0
  178. package/src/router/middleware-types.ts +222 -0
  179. package/src/router/middleware.ts +324 -367
  180. package/src/router/pattern-matching.ts +211 -43
  181. package/src/router/prerender-match.ts +402 -0
  182. package/src/router/preview-match.ts +170 -0
  183. package/src/router/revalidation.ts +137 -38
  184. package/src/router/router-context.ts +36 -21
  185. package/src/router/router-interfaces.ts +452 -0
  186. package/src/router/router-options.ts +592 -0
  187. package/src/router/router-registry.ts +24 -0
  188. package/src/router/segment-resolution/fresh.ts +570 -0
  189. package/src/router/segment-resolution/helpers.ts +263 -0
  190. package/src/router/segment-resolution/loader-cache.ts +198 -0
  191. package/src/router/segment-resolution/revalidation.ts +1241 -0
  192. package/src/router/segment-resolution/static-store.ts +67 -0
  193. package/src/router/segment-resolution.ts +21 -0
  194. package/src/router/segment-wrappers.ts +289 -0
  195. package/src/router/telemetry-otel.ts +299 -0
  196. package/src/router/telemetry.ts +300 -0
  197. package/src/router/timeout.ts +148 -0
  198. package/src/router/trie-matching.ts +239 -0
  199. package/src/router/types.ts +77 -3
  200. package/src/router.ts +692 -4257
  201. package/src/rsc/handler-context.ts +45 -0
  202. package/src/rsc/handler.ts +764 -754
  203. package/src/rsc/helpers.ts +140 -6
  204. package/src/rsc/index.ts +0 -20
  205. package/src/rsc/loader-fetch.ts +209 -0
  206. package/src/rsc/manifest-init.ts +86 -0
  207. package/src/rsc/nonce.ts +14 -0
  208. package/src/rsc/origin-guard.ts +141 -0
  209. package/src/rsc/progressive-enhancement.ts +379 -0
  210. package/src/rsc/response-error.ts +37 -0
  211. package/src/rsc/response-route-handler.ts +347 -0
  212. package/src/rsc/rsc-rendering.ts +235 -0
  213. package/src/rsc/runtime-warnings.ts +42 -0
  214. package/src/rsc/server-action.ts +348 -0
  215. package/src/rsc/ssr-setup.ts +128 -0
  216. package/src/rsc/types.ts +38 -11
  217. package/src/search-params.ts +230 -0
  218. package/src/segment-system.tsx +25 -13
  219. package/src/server/context.ts +182 -51
  220. package/src/server/cookie-store.ts +190 -0
  221. package/src/server/fetchable-loader-store.ts +37 -0
  222. package/src/server/handle-store.ts +94 -15
  223. package/src/server/loader-registry.ts +15 -56
  224. package/src/server/request-context.ts +430 -70
  225. package/src/server.ts +35 -130
  226. package/src/ssr/index.tsx +100 -31
  227. package/src/static-handler.ts +114 -0
  228. package/src/theme/ThemeProvider.tsx +21 -15
  229. package/src/theme/ThemeScript.tsx +5 -5
  230. package/src/theme/constants.ts +5 -2
  231. package/src/theme/index.ts +4 -14
  232. package/src/theme/theme-context.ts +4 -30
  233. package/src/theme/theme-script.ts +21 -18
  234. package/src/types/boundaries.ts +158 -0
  235. package/src/types/cache-types.ts +198 -0
  236. package/src/types/error-types.ts +192 -0
  237. package/src/types/global-namespace.ts +100 -0
  238. package/src/types/handler-context.ts +687 -0
  239. package/src/types/index.ts +88 -0
  240. package/src/types/loader-types.ts +183 -0
  241. package/src/types/route-config.ts +170 -0
  242. package/src/types/route-entry.ts +102 -0
  243. package/src/types/segments.ts +148 -0
  244. package/src/types.ts +1 -1623
  245. package/src/urls/include-helper.ts +197 -0
  246. package/src/urls/index.ts +53 -0
  247. package/src/urls/path-helper-types.ts +339 -0
  248. package/src/urls/path-helper.ts +329 -0
  249. package/src/urls/pattern-types.ts +95 -0
  250. package/src/urls/response-types.ts +106 -0
  251. package/src/urls/type-extraction.ts +372 -0
  252. package/src/urls/urls-function.ts +98 -0
  253. package/src/urls.ts +1 -802
  254. package/src/use-loader.tsx +85 -77
  255. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  256. package/src/vite/discovery/discover-routers.ts +344 -0
  257. package/src/vite/discovery/prerender-collection.ts +385 -0
  258. package/src/vite/discovery/route-types-writer.ts +258 -0
  259. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  260. package/src/vite/discovery/state.ts +110 -0
  261. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  262. package/src/vite/index.ts +11 -1133
  263. package/src/vite/plugin-types.ts +131 -0
  264. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  265. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  266. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  267. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -51
  268. package/src/vite/plugins/expose-id-utils.ts +287 -0
  269. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  270. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  271. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  272. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  273. package/src/vite/plugins/expose-ids/types.ts +45 -0
  274. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  275. package/src/vite/plugins/refresh-cmd.ts +65 -0
  276. package/src/vite/plugins/use-cache-transform.ts +323 -0
  277. package/src/vite/plugins/version-injector.ts +83 -0
  278. package/src/vite/plugins/version-plugin.ts +254 -0
  279. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
  280. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  281. package/src/vite/rango.ts +510 -0
  282. package/src/vite/router-discovery.ts +785 -0
  283. package/src/vite/utils/ast-handler-extract.ts +517 -0
  284. package/src/vite/utils/banner.ts +36 -0
  285. package/src/vite/utils/bundle-analysis.ts +137 -0
  286. package/src/vite/utils/manifest-utils.ts +70 -0
  287. package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
  288. package/src/vite/utils/prerender-utils.ts +189 -0
  289. package/src/vite/utils/shared-utils.ts +169 -0
  290. package/CLAUDE.md +0 -43
  291. package/src/browser/lru-cache.ts +0 -69
  292. package/src/browser/request-controller.ts +0 -164
  293. package/src/cache/memory-store.ts +0 -253
  294. package/src/href-context.ts +0 -33
  295. package/src/href.ts +0 -255
  296. package/src/server/route-manifest-cache.ts +0 -173
  297. package/src/vite/expose-handle-id.ts +0 -209
  298. package/src/vite/expose-loader-id.ts +0 -426
  299. package/src/vite/expose-location-state-id.ts +0 -177
  300. /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
  /**
@@ -116,15 +118,6 @@ export interface HandleState {
116
118
  segmentOrder: string[];
117
119
  }
118
120
 
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;
126
- }
127
-
128
121
  /**
129
122
  * Result from starting a navigation
130
123
  * Implements Disposable for use with `using` keyword
@@ -165,8 +158,8 @@ export interface ActionHandle extends Disposable {
165
158
  readonly settled: boolean;
166
159
  /** Check if any concurrent actions were started */
167
160
  hadConcurrentActions: boolean;
168
- /** Get segments to consolidate (only valid when this is the last action) */
169
- getConsolidationSegments(): string[] | null;
161
+ /** Get raw set of segments revalidated by concurrent actions */
162
+ getRevalidatedSegments(): Set<string>;
170
163
  /** Clear consolidation tracking */
171
164
  clearConsolidation(): void;
172
165
  }
@@ -176,7 +169,10 @@ export interface ActionHandle extends Disposable {
176
169
  */
177
170
  export interface EventController {
178
171
  // Navigation operations
179
- startNavigation(url: string, options?: NavigateOptions): NavigationHandle;
172
+ startNavigation(
173
+ url: string,
174
+ options?: NavigateOptions & { skipLoadingState?: boolean },
175
+ ): NavigationHandle;
180
176
  abortNavigation(): void;
181
177
 
182
178
  // Action operations
@@ -186,6 +182,7 @@ export interface EventController {
186
182
  // State access
187
183
  getState(): DerivedNavigationState;
188
184
  getActionState(actionId: string): TrackedActionState;
185
+ getLocation(): NavigationLocation;
189
186
 
190
187
  // Location updates (for popstate where navigation doesn't go through startNavigation)
191
188
  setLocation(location: NavigationLocation): void;
@@ -194,7 +191,7 @@ export interface EventController {
194
191
  subscribe(listener: StateListener): () => void;
195
192
  subscribeToAction(
196
193
  actionId: string,
197
- listener: ActionStateListener
194
+ listener: ActionStateListener,
198
195
  ): () => void;
199
196
  subscribeToHandles(listener: HandleListener): () => void;
200
197
 
@@ -202,13 +199,19 @@ export interface EventController {
202
199
  setHandleData(
203
200
  data: HandleData,
204
201
  matched?: string[],
205
- isPartial?: boolean
202
+ isPartial?: boolean,
206
203
  ): void;
207
204
  getHandleState(): HandleState;
208
205
 
206
+ // Params operations
207
+ setParams(params: Record<string, string>): void;
208
+ getParams(): Record<string, string>;
209
+
209
210
  // Direct state access for advanced use
210
211
  getCurrentNavigation(): NavigationEntry | null;
211
212
  getInflightActions(): Map<string, ActionEntry>;
213
+ /** Whether any concurrent actions have occurred (shared across all handles) */
214
+ hadAnyConcurrentActions(): boolean;
212
215
  }
213
216
 
214
217
  // ============================================================================
@@ -230,7 +233,10 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
230
233
  * When subscriptionId has no '#', it's just an action name and matches by suffix.
231
234
  * This allows useAction("addToCart") to match "hash#addToCart" or "src/file.ts#addToCart".
232
235
  */
233
- function matchesActionId(subscriptionId: string, entryActionId: string): boolean {
236
+ function matchesActionId(
237
+ subscriptionId: string,
238
+ entryActionId: string,
239
+ ): boolean {
234
240
  if (subscriptionId.includes("#")) {
235
241
  // Full ID: exact match
236
242
  return subscriptionId === entryActionId;
@@ -261,7 +267,7 @@ export interface EventControllerConfig {
261
267
  * Actions use mergeMap semantics (all run concurrently, consolidate at end).
262
268
  */
263
269
  export function createEventController(
264
- config?: EventControllerConfig
270
+ config?: EventControllerConfig,
265
271
  ): EventController {
266
272
  // ========================================================================
267
273
  // Source of Truth
@@ -293,6 +299,9 @@ export function createEventController(
293
299
  let handleData: HandleData = {};
294
300
  let handleSegmentOrder: string[] = [];
295
301
 
302
+ // Merged route params from current match
303
+ let routeParams: Record<string, string> = {};
304
+
296
305
  // ========================================================================
297
306
  // Listeners
298
307
  // ========================================================================
@@ -334,7 +343,7 @@ export function createEventController(
334
343
  listeners.forEach((listener) => listener(state));
335
344
  }
336
345
  }
337
- }, 0)
346
+ }, 0),
338
347
  );
339
348
  }
340
349
 
@@ -367,9 +376,12 @@ export function createEventController(
367
376
  }));
368
377
 
369
378
  // State: loading if navigation OR actions are in progress
379
+ // Background revalidations (skipLoadingState) don't affect visible state
370
380
  const hasActiveActions = inflightActionsList.length > 0;
371
- const state =
372
- currentNavigation !== null || hasActiveActions ? "loading" : "idle";
381
+ const isVisibleNavigation =
382
+ currentNavigation !== null &&
383
+ !currentNavigation.options?.skipLoadingState;
384
+ const state = isVisibleNavigation || hasActiveActions ? "loading" : "idle";
373
385
 
374
386
  // Streaming: true if any active streams (navigation or action) or loading
375
387
  const isStreaming = activeStreamCount > 0 || state === "loading";
@@ -378,8 +390,13 @@ export function createEventController(
378
390
  state,
379
391
  isStreaming,
380
392
  location,
381
- // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending
382
- pendingUrl: currentNavigation?.phase === "fetching" ? currentNavigation.url : null,
393
+ // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
394
+ // Background revalidations (skipLoadingState) don't expose a pending URL.
395
+ pendingUrl:
396
+ currentNavigation?.phase === "fetching" &&
397
+ !currentNavigation.options?.skipLoadingState
398
+ ? currentNavigation.url
399
+ : null,
383
400
  inflightActions: inflightActionsList,
384
401
  };
385
402
  }
@@ -388,12 +405,16 @@ export function createEventController(
388
405
  // Find the most recent action with this ID that's not settling
389
406
  // Uses suffix matching when actionId is just a name (no #)
390
407
  const activeEntry = [...inflightActions.values()]
391
- .filter((a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling")
408
+ .filter(
409
+ (a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling",
410
+ )
392
411
  .sort((a, b) => b.startedAt - a.startedAt)[0];
393
412
 
394
413
  // Also check for settling entries to get result/error
395
414
  const settlingEntry = [...inflightActions.values()]
396
- .filter((a) => matchesActionId(actionId, a.actionId) && a.phase === "settling")
415
+ .filter(
416
+ (a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
417
+ )
397
418
  .sort((a, b) => b.startedAt - a.startedAt)[0];
398
419
 
399
420
  const entry = activeEntry || settlingEntry;
@@ -431,7 +452,7 @@ export function createEventController(
431
452
 
432
453
  function startNavigation(
433
454
  url: string,
434
- options?: NavigateOptions
455
+ options?: NavigateOptions & { skipLoadingState?: boolean },
435
456
  ): NavigationHandle {
436
457
  // Cancel existing navigation (switchMap semantics)
437
458
  if (currentNavigation) {
@@ -463,6 +484,7 @@ export function createEventController(
463
484
 
464
485
  startStreaming(): StreamingToken {
465
486
  let ended = false;
487
+ entry.phase = "streaming";
466
488
  activeStreamCount++;
467
489
  notify();
468
490
 
@@ -650,24 +672,8 @@ export function createEventController(
650
672
  // If streaming is in progress, tryFinalize() will be called when streaming ends
651
673
  },
652
674
 
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);
675
+ getRevalidatedSegments(): Set<string> {
676
+ return concurrentRevalidatedSegments;
671
677
  },
672
678
 
673
679
  clearConsolidation() {
@@ -702,16 +708,26 @@ export function createEventController(
702
708
  }
703
709
 
704
710
  function abortAllActions() {
705
- for (const entry of inflightActions.values()) {
711
+ for (const [id, entry] of inflightActions) {
712
+ // Preserve settling entries — they have already been handled by
713
+ // fail()/complete() and will self-cleanup via the settlement timeout.
714
+ // Clearing them here would prevent debounced notifications from
715
+ // delivering the error/result state to subscribers.
716
+ if (entry.phase === "settling") continue;
706
717
  entry.abort.abort();
718
+ inflightActions.delete(id);
707
719
  }
708
- inflightActions.clear();
709
720
  hadAnyConcurrentActions = false;
710
721
  concurrentRevalidatedSegments.clear();
711
722
  notify();
712
- // Notify all action listeners
713
- for (const actionId of actionListeners.keys()) {
714
- notifyAction(actionId);
723
+ // Notify all action listeners directly by subscription ID.
724
+ // actionListeners keys are subscription IDs (possibly short names like
725
+ // "addToCart"), not full entry actionIds. Passing them to notifyAction
726
+ // would fail the suffix matcher — instead, notify each subscriber with
727
+ // its own state.
728
+ for (const [subscriptionId, listeners] of actionListeners) {
729
+ const state = getActionState(subscriptionId);
730
+ listeners.forEach((listener) => listener(state));
715
731
  }
716
732
  }
717
733
 
@@ -719,22 +735,10 @@ export function createEventController(
719
735
  // Handle Operations
720
736
  // ========================================================================
721
737
 
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
738
  function setHandleData(
735
739
  data: HandleData,
736
740
  matched?: string[],
737
- isPartial?: boolean
741
+ isPartial?: boolean,
738
742
  ): void {
739
743
  const newSegmentOrder = filterSegmentOrder(matched ?? []);
740
744
 
@@ -783,7 +787,7 @@ export function createEventController(
783
787
 
784
788
  function subscribeToAction(
785
789
  actionId: string,
786
- listener: ActionStateListener
790
+ listener: ActionStateListener,
787
791
  ): () => void {
788
792
  let listeners = actionListeners.get(actionId);
789
793
  if (!listeners) {
@@ -805,6 +809,19 @@ export function createEventController(
805
809
  return () => handleListeners.delete(listener);
806
810
  }
807
811
 
812
+ // ========================================================================
813
+ // Params Operations
814
+ // ========================================================================
815
+
816
+ function setParams(params: Record<string, string>): void {
817
+ routeParams = params;
818
+ notify();
819
+ }
820
+
821
+ function getParams(): Record<string, string> {
822
+ return routeParams;
823
+ }
824
+
808
825
  // ========================================================================
809
826
  // Return Controller
810
827
  // ========================================================================
@@ -821,12 +838,17 @@ export function createEventController(
821
838
  // State
822
839
  getState,
823
840
  getActionState,
841
+ getLocation: () => location,
824
842
  setLocation,
825
843
 
826
844
  // Handles
827
845
  setHandleData,
828
846
  getHandleState,
829
847
 
848
+ // Params
849
+ setParams,
850
+ getParams,
851
+
830
852
  // Subscriptions
831
853
  subscribe,
832
854
  subscribeToAction,
@@ -835,6 +857,7 @@ export function createEventController(
835
857
  // Direct access
836
858
  getCurrentNavigation: () => currentNavigation,
837
859
  getInflightActions: () => inflightActions,
860
+ hadAnyConcurrentActions: () => hadAnyConcurrentActions,
838
861
  };
839
862
  }
840
863
 
@@ -848,7 +871,7 @@ let controllerInstance: EventController | null = null;
848
871
  * Initialize the global event controller
849
872
  */
850
873
  export function initEventController(
851
- config?: EventControllerConfig
874
+ config?: EventControllerConfig,
852
875
  ): EventController {
853
876
  if (!controllerInstance) {
854
877
  controllerInstance = createEventController(config);
@@ -862,7 +885,7 @@ export function initEventController(
862
885
  export function getEventController(): EventController {
863
886
  if (!controllerInstance) {
864
887
  throw new Error(
865
- "Event controller not initialized. Call initEventController first."
888
+ "Event controller not initialized. Call initEventController first.",
866
889
  );
867
890
  }
868
891
  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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
+ import { debugLog } from "./logging.js";
2
3
 
3
4
  /**
4
5
  * Merge partial loader data from server with cached loader data.
@@ -13,13 +14,13 @@ import type { ResolvedSegment } from "./types.js";
13
14
  */
14
15
  export function mergeSegmentLoaders(
15
16
  fromServer: ResolvedSegment,
16
- fromCache: ResolvedSegment
17
+ fromCache: ResolvedSegment,
17
18
  ): ResolvedSegment {
18
19
  const serverLoaderIds = fromServer.loaderIds || [];
19
20
  const cachedLoaderIds = fromCache.loaderIds || [];
20
21
 
21
- console.log(
22
- `[Browser] Merging partial loaders: server has ${serverLoaderIds.join(", ")}, cache has ${cachedLoaderIds.join(", ")}`
22
+ debugLog(
23
+ `[Browser] Merging partial loaders: server has ${serverLoaderIds.join(", ")}, cache has ${cachedLoaderIds.join(", ")}`,
23
24
  );
24
25
 
25
26
  return {
@@ -54,7 +55,7 @@ export function mergeSegmentLoaders(
54
55
  */
55
56
  export function needsLoaderMerge(
56
57
  fromServer: ResolvedSegment,
57
- fromCache: ResolvedSegment | undefined
58
+ fromCache: ResolvedSegment | undefined,
58
59
  ): fromCache is ResolvedSegment {
59
60
  return !!(
60
61
  fromCache &&
@@ -86,10 +87,15 @@ export function insertMissingDiffSegments(
86
87
  allSegments: ResolvedSegment[],
87
88
  diff: string[] | undefined,
88
89
  matchedIdSet: Set<string>,
89
- newSegmentMap: Map<string, ResolvedSegment>
90
+ newSegmentMap: Map<string, ResolvedSegment>,
90
91
  ): void {
91
92
  if (!diff || diff.length === 0) return;
92
93
 
94
+ // Track how many siblings have been inserted per parent so each new
95
+ // sibling goes after the last one rather than always at parentIndex + 1
96
+ // (which would reverse the server order).
97
+ const insertedPerParent = new Map<string, number>();
98
+
93
99
  diff.forEach((diffId: string) => {
94
100
  if (!matchedIdSet.has(diffId)) {
95
101
  const fromServer = newSegmentMap.get(diffId);
@@ -100,25 +106,27 @@ export function insertMissingDiffSegments(
100
106
  if (loaderMatch) {
101
107
  const parentLayoutId = loaderMatch[1];
102
108
  const parentIndex = allSegments.findIndex(
103
- (s) => s.id === parentLayoutId
109
+ (s) => s.id === parentLayoutId,
104
110
  );
105
111
  if (parentIndex !== -1) {
106
- // Insert loader segment right after its parent layout
107
- allSegments.splice(parentIndex + 1, 0, fromServer);
108
- console.log(
109
- `[Browser] Inserted diff segment ${diffId} after ${parentLayoutId}`
112
+ const alreadyInserted = insertedPerParent.get(parentLayoutId) ?? 0;
113
+ const insertAt = parentIndex + 1 + alreadyInserted;
114
+ allSegments.splice(insertAt, 0, fromServer);
115
+ insertedPerParent.set(parentLayoutId, alreadyInserted + 1);
116
+ debugLog(
117
+ `[Browser] Inserted diff segment ${diffId} after ${parentLayoutId}`,
110
118
  );
111
119
  } else {
112
120
  // Fallback: append to end if parent not found
113
121
  allSegments.push(fromServer);
114
122
  console.warn(
115
- `[Browser] Appended diff segment ${diffId} (parent ${parentLayoutId} not found)`
123
+ `[Browser] Appended diff segment ${diffId} (parent ${parentLayoutId} not found)`,
116
124
  );
117
125
  }
118
126
  } else {
119
127
  // Non-loader diff segment not in matched - append to end
120
128
  allSegments.push(fromServer);
121
- console.log(`[Browser] Appended diff segment ${diffId}`);
129
+ debugLog(`[Browser] Appended diff segment ${diffId}`);
122
130
  }
123
131
  }
124
132
  }