@rangojs/router 0.0.0-experimental.3 → 0.0.0-experimental.30

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 (297) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +883 -4
  3. package/dist/bin/rango.js +1601 -0
  4. package/dist/vite/index.js +4655 -747
  5. package/package.json +78 -50
  6. package/skills/cache-guide/SKILL.md +262 -0
  7. package/skills/caching/SKILL.md +54 -25
  8. package/skills/composability/SKILL.md +172 -0
  9. package/skills/debug-manifest/SKILL.md +12 -8
  10. package/skills/document-cache/SKILL.md +23 -21
  11. package/skills/fonts/SKILL.md +167 -0
  12. package/skills/hooks/SKILL.md +390 -63
  13. package/skills/host-router/SKILL.md +218 -0
  14. package/skills/intercept/SKILL.md +133 -10
  15. package/skills/layout/SKILL.md +102 -5
  16. package/skills/links/SKILL.md +239 -0
  17. package/skills/loader/SKILL.md +366 -29
  18. package/skills/middleware/SKILL.md +173 -36
  19. package/skills/mime-routes/SKILL.md +128 -0
  20. package/skills/parallel/SKILL.md +80 -3
  21. package/skills/prerender/SKILL.md +643 -0
  22. package/skills/rango/SKILL.md +86 -16
  23. package/skills/response-routes/SKILL.md +411 -0
  24. package/skills/route/SKILL.md +227 -14
  25. package/skills/router-setup/SKILL.md +225 -32
  26. package/skills/tailwind/SKILL.md +129 -0
  27. package/skills/theme/SKILL.md +12 -11
  28. package/skills/typesafety/SKILL.md +401 -75
  29. package/skills/use-cache/SKILL.md +324 -0
  30. package/src/__internal.ts +10 -4
  31. package/src/bin/rango.ts +321 -0
  32. package/src/browser/action-coordinator.ts +97 -0
  33. package/src/browser/action-response-classifier.ts +99 -0
  34. package/src/browser/event-controller.ts +87 -64
  35. package/src/browser/history-state.ts +80 -0
  36. package/src/browser/intercept-utils.ts +52 -0
  37. package/src/browser/link-interceptor.ts +20 -4
  38. package/src/browser/logging.ts +55 -0
  39. package/src/browser/merge-segment-loaders.ts +20 -12
  40. package/src/browser/navigation-bridge.ts +201 -553
  41. package/src/browser/navigation-client.ts +124 -71
  42. package/src/browser/navigation-store.ts +33 -50
  43. package/src/browser/navigation-transaction.ts +295 -0
  44. package/src/browser/network-error-handler.ts +61 -0
  45. package/src/browser/partial-update.ts +267 -317
  46. package/src/browser/prefetch/cache.ts +146 -0
  47. package/src/browser/prefetch/fetch.ts +135 -0
  48. package/src/browser/prefetch/observer.ts +65 -0
  49. package/src/browser/prefetch/policy.ts +42 -0
  50. package/src/browser/prefetch/queue.ts +88 -0
  51. package/src/browser/rango-state.ts +112 -0
  52. package/src/browser/react/Link.tsx +173 -73
  53. package/src/browser/react/NavigationProvider.tsx +138 -27
  54. package/src/browser/react/context.ts +6 -0
  55. package/src/browser/react/filter-segment-order.ts +11 -0
  56. package/src/browser/react/index.ts +12 -12
  57. package/src/browser/react/location-state-shared.ts +95 -53
  58. package/src/browser/react/location-state.ts +60 -15
  59. package/src/browser/react/mount-context.ts +37 -0
  60. package/src/browser/react/nonce-context.ts +23 -0
  61. package/src/browser/react/shallow-equal.ts +27 -0
  62. package/src/browser/react/use-action.ts +29 -51
  63. package/src/browser/react/use-client-cache.ts +5 -3
  64. package/src/browser/react/use-handle.ts +49 -65
  65. package/src/browser/react/use-href.tsx +20 -188
  66. package/src/browser/react/use-link-status.ts +6 -5
  67. package/src/browser/react/use-mount.ts +31 -0
  68. package/src/browser/react/use-navigation.ts +27 -78
  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 +111 -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 +83 -0
  79. package/src/browser/server-action-bridge.ts +504 -584
  80. package/src/browser/shallow.ts +6 -1
  81. package/src/browser/types.ts +92 -57
  82. package/src/browser/validate-redirect-origin.ts +29 -0
  83. package/src/build/generate-manifest.ts +438 -0
  84. package/src/build/generate-route-types.ts +36 -0
  85. package/src/build/index.ts +35 -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 +10 -15
  114. package/src/client.tsx +114 -135
  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 +34 -19
  121. package/src/handles/MetaTags.tsx +73 -20
  122. package/src/handles/meta.ts +30 -13
  123. package/src/host/cookie-handler.ts +165 -0
  124. package/src/host/errors.ts +97 -0
  125. package/src/host/index.ts +53 -0
  126. package/src/host/pattern-matcher.ts +214 -0
  127. package/src/host/router.ts +352 -0
  128. package/src/host/testing.ts +79 -0
  129. package/src/host/types.ts +146 -0
  130. package/src/host/utils.ts +25 -0
  131. package/src/href-client.ts +135 -49
  132. package/src/index.rsc.ts +182 -17
  133. package/src/index.ts +238 -24
  134. package/src/internal-debug.ts +11 -0
  135. package/src/loader.rsc.ts +27 -142
  136. package/src/loader.ts +27 -10
  137. package/src/network-error-thrower.tsx +3 -1
  138. package/src/outlet-provider.tsx +45 -0
  139. package/src/prerender/param-hash.ts +37 -0
  140. package/src/prerender/store.ts +185 -0
  141. package/src/prerender.ts +463 -0
  142. package/src/reverse.ts +330 -0
  143. package/src/root-error-boundary.tsx +41 -29
  144. package/src/route-content-wrapper.tsx +9 -11
  145. package/src/route-definition/dsl-helpers.ts +934 -0
  146. package/src/route-definition/helper-factories.ts +200 -0
  147. package/src/route-definition/helpers-types.ts +430 -0
  148. package/src/route-definition/index.ts +52 -0
  149. package/src/route-definition/redirect.ts +93 -0
  150. package/src/route-definition.ts +1 -1388
  151. package/src/route-map-builder.ts +241 -112
  152. package/src/route-name.ts +53 -0
  153. package/src/route-types.ts +70 -9
  154. package/src/router/content-negotiation.ts +116 -0
  155. package/src/router/debug-manifest.ts +72 -0
  156. package/src/router/error-handling.ts +9 -9
  157. package/src/router/find-match.ts +158 -0
  158. package/src/router/handler-context.ts +371 -81
  159. package/src/router/intercept-resolution.ts +395 -0
  160. package/src/router/lazy-includes.ts +234 -0
  161. package/src/router/loader-resolution.ts +215 -122
  162. package/src/router/logging.ts +248 -0
  163. package/src/router/manifest.ts +155 -32
  164. package/src/router/match-api.ts +620 -0
  165. package/src/router/match-context.ts +5 -3
  166. package/src/router/match-handlers.ts +440 -0
  167. package/src/router/match-middleware/background-revalidation.ts +80 -93
  168. package/src/router/match-middleware/cache-lookup.ts +382 -9
  169. package/src/router/match-middleware/cache-store.ts +51 -22
  170. package/src/router/match-middleware/intercept-resolution.ts +55 -17
  171. package/src/router/match-middleware/segment-resolution.ts +24 -6
  172. package/src/router/match-pipelines.ts +10 -45
  173. package/src/router/match-result.ts +34 -29
  174. package/src/router/metrics.ts +235 -15
  175. package/src/router/middleware-cookies.ts +55 -0
  176. package/src/router/middleware-types.ts +222 -0
  177. package/src/router/middleware.ts +324 -367
  178. package/src/router/pattern-matching.ts +321 -30
  179. package/src/router/prerender-match.ts +400 -0
  180. package/src/router/preview-match.ts +170 -0
  181. package/src/router/revalidation.ts +137 -38
  182. package/src/router/router-context.ts +36 -21
  183. package/src/router/router-interfaces.ts +452 -0
  184. package/src/router/router-options.ts +592 -0
  185. package/src/router/router-registry.ts +24 -0
  186. package/src/router/segment-resolution/fresh.ts +570 -0
  187. package/src/router/segment-resolution/helpers.ts +263 -0
  188. package/src/router/segment-resolution/loader-cache.ts +198 -0
  189. package/src/router/segment-resolution/revalidation.ts +1241 -0
  190. package/src/router/segment-resolution/static-store.ts +67 -0
  191. package/src/router/segment-resolution.ts +21 -0
  192. package/src/router/segment-wrappers.ts +289 -0
  193. package/src/router/telemetry-otel.ts +299 -0
  194. package/src/router/telemetry.ts +300 -0
  195. package/src/router/timeout.ts +148 -0
  196. package/src/router/trie-matching.ts +239 -0
  197. package/src/router/types.ts +77 -3
  198. package/src/router.ts +688 -3656
  199. package/src/rsc/handler-context.ts +45 -0
  200. package/src/rsc/handler.ts +786 -760
  201. package/src/rsc/helpers.ts +140 -6
  202. package/src/rsc/index.ts +5 -25
  203. package/src/rsc/loader-fetch.ts +209 -0
  204. package/src/rsc/manifest-init.ts +86 -0
  205. package/src/rsc/nonce.ts +14 -0
  206. package/src/rsc/origin-guard.ts +141 -0
  207. package/src/rsc/progressive-enhancement.ts +379 -0
  208. package/src/rsc/response-error.ts +37 -0
  209. package/src/rsc/response-route-handler.ts +347 -0
  210. package/src/rsc/rsc-rendering.ts +235 -0
  211. package/src/rsc/runtime-warnings.ts +42 -0
  212. package/src/rsc/server-action.ts +348 -0
  213. package/src/rsc/ssr-setup.ts +128 -0
  214. package/src/rsc/types.ts +40 -14
  215. package/src/search-params.ts +230 -0
  216. package/src/segment-system.tsx +57 -61
  217. package/src/server/context.ts +202 -51
  218. package/src/server/cookie-store.ts +190 -0
  219. package/src/server/fetchable-loader-store.ts +37 -0
  220. package/src/server/handle-store.ts +94 -15
  221. package/src/server/loader-registry.ts +15 -56
  222. package/src/server/request-context.ts +422 -70
  223. package/src/server.ts +36 -120
  224. package/src/ssr/index.tsx +157 -26
  225. package/src/static-handler.ts +114 -0
  226. package/src/theme/ThemeProvider.tsx +21 -15
  227. package/src/theme/ThemeScript.tsx +5 -5
  228. package/src/theme/constants.ts +5 -2
  229. package/src/theme/index.ts +4 -14
  230. package/src/theme/theme-context.ts +4 -30
  231. package/src/theme/theme-script.ts +21 -18
  232. package/src/types/boundaries.ts +158 -0
  233. package/src/types/cache-types.ts +198 -0
  234. package/src/types/error-types.ts +192 -0
  235. package/src/types/global-namespace.ts +100 -0
  236. package/src/types/handler-context.ts +687 -0
  237. package/src/types/index.ts +88 -0
  238. package/src/types/loader-types.ts +183 -0
  239. package/src/types/route-config.ts +170 -0
  240. package/src/types/route-entry.ts +102 -0
  241. package/src/types/segments.ts +148 -0
  242. package/src/types.ts +1 -1577
  243. package/src/urls/include-helper.ts +197 -0
  244. package/src/urls/index.ts +53 -0
  245. package/src/urls/path-helper-types.ts +339 -0
  246. package/src/urls/path-helper.ts +329 -0
  247. package/src/urls/pattern-types.ts +95 -0
  248. package/src/urls/response-types.ts +106 -0
  249. package/src/urls/type-extraction.ts +372 -0
  250. package/src/urls/urls-function.ts +98 -0
  251. package/src/urls.ts +1 -726
  252. package/src/use-loader.tsx +85 -77
  253. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  254. package/src/vite/discovery/discover-routers.ts +344 -0
  255. package/src/vite/discovery/prerender-collection.ts +385 -0
  256. package/src/vite/discovery/route-types-writer.ts +258 -0
  257. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  258. package/src/vite/discovery/state.ts +110 -0
  259. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  260. package/src/vite/index.ts +11 -782
  261. package/src/vite/plugin-types.ts +131 -0
  262. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  263. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  264. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  265. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -51
  266. package/src/vite/plugins/expose-id-utils.ts +287 -0
  267. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  268. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  269. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  270. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  271. package/src/vite/plugins/expose-ids/types.ts +45 -0
  272. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  273. package/src/vite/plugins/refresh-cmd.ts +65 -0
  274. package/src/vite/plugins/use-cache-transform.ts +323 -0
  275. package/src/vite/plugins/version-injector.ts +83 -0
  276. package/src/vite/plugins/version-plugin.ts +254 -0
  277. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +29 -15
  278. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  279. package/src/vite/rango.ts +510 -0
  280. package/src/vite/router-discovery.ts +785 -0
  281. package/src/vite/utils/ast-handler-extract.ts +517 -0
  282. package/src/vite/utils/banner.ts +36 -0
  283. package/src/vite/utils/bundle-analysis.ts +137 -0
  284. package/src/vite/utils/manifest-utils.ts +70 -0
  285. package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
  286. package/src/vite/utils/prerender-utils.ts +189 -0
  287. package/src/vite/utils/shared-utils.ts +169 -0
  288. package/CLAUDE.md +0 -3
  289. package/src/browser/lru-cache.ts +0 -69
  290. package/src/browser/request-controller.ts +0 -164
  291. package/src/cache/memory-store.ts +0 -253
  292. package/src/href-context.ts +0 -33
  293. package/src/href.ts +0 -255
  294. package/src/vite/expose-handle-id.ts +0 -209
  295. package/src/vite/expose-loader-id.ts +0 -357
  296. package/src/vite/expose-location-state-id.ts +0 -177
  297. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -0,0 +1,97 @@
1
+ import {
2
+ classifyActionResponse,
3
+ type ActionScenario,
4
+ } from "./action-response-classifier.js";
5
+ import type { ActionEntry } from "./event-controller.js";
6
+
7
+ /**
8
+ * Plain data inputs for classifying a post-reconciliation action outcome.
9
+ * No browser objects or controller references — all values are snapshots.
10
+ */
11
+ export interface ActionOutcomeInput {
12
+ /** This action's unique instance ID */
13
+ handleId: string;
14
+ /** All in-flight action entries (snapshot from event controller) */
15
+ inflightActions: Map<string, ActionEntry>;
16
+ /** Whether any concurrent actions occurred (controller-level shared flag) */
17
+ hadAnyConcurrentActions: boolean;
18
+ /** Segments revalidated by concurrent actions (from tracking set) */
19
+ revalidatedSegments: Set<string>;
20
+ /** window.location.pathname captured at action start */
21
+ actionStartPathname: string;
22
+ /** window.location.pathname at classification time */
23
+ currentPathname: string;
24
+ /** window.history.state?.key captured at action start */
25
+ actionStartLocationKey: string | undefined;
26
+ /** window.history.state?.key at classification time */
27
+ currentLocationKey: string | undefined;
28
+ /** Number of segments after reconciliation */
29
+ reconciledSegmentCount: number;
30
+ /** Number of matched segment IDs from server */
31
+ matchedCount: number;
32
+ /** Current intercept source URL (null when not on intercept route) */
33
+ currentInterceptSource: string | null;
34
+ }
35
+
36
+ /**
37
+ * Compute consolidation segments from concurrent action state.
38
+ *
39
+ * Returns segment IDs that need re-fetching when concurrent actions
40
+ * have each revalidated different parts of the tree, or null if
41
+ * consolidation is not needed.
42
+ */
43
+ function computeConsolidationSegments(
44
+ input: ActionOutcomeInput,
45
+ ): string[] | null {
46
+ if (!input.hadAnyConcurrentActions) return null;
47
+ if (input.revalidatedSegments.size === 0) return null;
48
+
49
+ // Can't consolidate while any action is still waiting for a server response
50
+ const stillFetchingCount = [...input.inflightActions.values()].filter(
51
+ (a) => a.phase === "fetching",
52
+ ).length;
53
+ if (stillFetchingCount > 0) return null;
54
+
55
+ return Array.from(input.revalidatedSegments);
56
+ }
57
+
58
+ /**
59
+ * Count other actions still in "fetching" phase (excluding this handle).
60
+ */
61
+ function countOtherFetchingActions(input: ActionOutcomeInput): number {
62
+ let count = 0;
63
+ for (const [, a] of input.inflightActions) {
64
+ if (a.phase === "fetching" && a.id !== input.handleId) {
65
+ count++;
66
+ }
67
+ }
68
+ return count;
69
+ }
70
+
71
+ /**
72
+ * Classify a post-reconciliation action outcome into one of 5 scenarios.
73
+ *
74
+ * This is the single entry point for post-action decision logic.
75
+ * It gathers consolidation and concurrency data from the plain inputs,
76
+ * then delegates to the pure classifyActionResponse function.
77
+ *
78
+ * The server-action-bridge calls this after reconciliation to decide
79
+ * whether to render, skip, consolidate, or refetch.
80
+ */
81
+ export function classifyActionOutcome(
82
+ input: ActionOutcomeInput,
83
+ ): ActionScenario {
84
+ return classifyActionResponse({
85
+ actionStartPathname: input.actionStartPathname,
86
+ currentPathname: input.currentPathname,
87
+ actionStartLocationKey: input.actionStartLocationKey,
88
+ currentLocationKey: input.currentLocationKey,
89
+ reconciledSegmentCount: input.reconciledSegmentCount,
90
+ matchedCount: input.matchedCount,
91
+ currentInterceptSource: input.currentInterceptSource,
92
+ consolidationSegments: computeConsolidationSegments(input),
93
+ otherFetchingActionCount: countOtherFetchingActions(input),
94
+ });
95
+ }
96
+
97
+ export type { ActionScenario };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Discriminated union of post-reconciliation action response scenarios.
3
+ *
4
+ * Error and full-update-unsupported are handled inline in the bridge
5
+ * before reconciliation. This classifier only runs for partial responses
6
+ * that have been successfully reconciled.
7
+ */
8
+ export type ActionScenario =
9
+ | {
10
+ type: "navigated-away";
11
+ historyKeyChanged: boolean;
12
+ onInterceptRoute: boolean;
13
+ }
14
+ | { type: "hmr-missing" }
15
+ | { type: "consolidation-needed"; segmentIds: string[] }
16
+ | { type: "concurrent-skip"; otherFetchingCount: number }
17
+ | { type: "normal" };
18
+
19
+ /**
20
+ * Pure data inputs for classifying a partial action response.
21
+ * All values come from the bridge but no browser APIs or side effects.
22
+ */
23
+ export interface ClassifierInput {
24
+ /** window.location.pathname captured at action start */
25
+ actionStartPathname: string;
26
+ /** window.location.pathname at classification time */
27
+ currentPathname: string;
28
+ /** window.history.state?.key captured at action start */
29
+ actionStartLocationKey: string | undefined;
30
+ /** window.history.state?.key at classification time */
31
+ currentLocationKey: string | undefined;
32
+ /** Number of segments after reconciliation */
33
+ reconciledSegmentCount: number;
34
+ /** Number of matched segment IDs from server */
35
+ matchedCount: number;
36
+ /** Segment IDs needing consolidation (from concurrent action tracking) */
37
+ consolidationSegments: string[] | null;
38
+ /** Number of other actions still in "fetching" phase */
39
+ otherFetchingActionCount: number;
40
+ /** Current intercept source URL (null when not on intercept route) */
41
+ currentInterceptSource: string | null;
42
+ }
43
+
44
+ /**
45
+ * Classify a partial action response into one of 5 post-reconciliation
46
+ * scenarios.
47
+ *
48
+ * Called after error and full-update cases are handled inline by the bridge.
49
+ * The classification order matches the priority chain:
50
+ * 1. User navigated away during action
51
+ * 2. HMR missing segments (fewer reconciled than matched)
52
+ * 3. Consolidation needed (concurrent actions finished)
53
+ * 4. Concurrent skip (other actions still fetching)
54
+ * 5. Normal (single action, no issues)
55
+ *
56
+ * This is a pure function with no side effects - the bridge handles
57
+ * all UI updates, store mutations, and network requests based on the
58
+ * returned scenario.
59
+ */
60
+ export function classifyActionResponse(input: ClassifierInput): ActionScenario {
61
+ // Check if user navigated away during the action
62
+ const userNavigatedAway =
63
+ input.currentPathname !== input.actionStartPathname ||
64
+ input.currentLocationKey !== input.actionStartLocationKey;
65
+
66
+ if (userNavigatedAway) {
67
+ const historyKeyChanged =
68
+ input.currentLocationKey !== input.actionStartLocationKey;
69
+ return {
70
+ type: "navigated-away",
71
+ historyKeyChanged,
72
+ onInterceptRoute: input.currentInterceptSource !== null,
73
+ };
74
+ }
75
+
76
+ // HMR resilience: segments missing after reconciliation
77
+ if (input.reconciledSegmentCount < input.matchedCount) {
78
+ return { type: "hmr-missing" };
79
+ }
80
+
81
+ // Consolidation needed for concurrent actions
82
+ if (input.consolidationSegments && input.consolidationSegments.length > 0) {
83
+ return {
84
+ type: "consolidation-needed",
85
+ segmentIds: input.consolidationSegments,
86
+ };
87
+ }
88
+
89
+ // Other actions still fetching - skip UI update
90
+ if (input.otherFetchingActionCount > 0) {
91
+ return {
92
+ type: "concurrent-skip",
93
+ otherFetchingCount: input.otherFetchingActionCount,
94
+ };
95
+ }
96
+
97
+ // Normal single-action completion
98
+ return { type: "normal" };
99
+ }
@@ -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
+ }