@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -17,8 +17,7 @@ import { urlpatterns } from "./urls";
17
17
 
18
18
  const router = createRouter<AppBindings>({
19
19
  document: Document,
20
- urls: urlpatterns,
21
- });
20
+ }).routes(urlpatterns);
22
21
 
23
22
  // Server-side named-route reverse (type-safe via routeMap)
24
23
  export const reverse = router.reverse;
@@ -26,6 +25,39 @@ export const reverse = router.reverse;
26
25
  export default router;
27
26
  ```
28
27
 
28
+ ### Which global type should I use?
29
+
30
+ Use the generated route map by default. Manual `RegisteredRoutes` augmentation
31
+ is only needed when you want the richer `typeof router.routeMap` shape
32
+ available globally.
33
+
34
+ - `GeneratedRouteMap` — auto-registered by `router.named-routes.gen.ts`
35
+ Use for `Handler<"name">`, `Prerender<"name">`, server `ctx.reverse()`,
36
+ and named-route param/search inference.
37
+ - `typeof router.routeMap` — the real merged route map from your router
38
+ instance, including response-route metadata such as `{ path, response }`.
39
+ - `RegisteredRoutes` — manual global hook for exposing `typeof router.routeMap`
40
+ to utilities like `href()`, `ValidPaths`, and `PathResponse`.
41
+
42
+ Recommended setup:
43
+
44
+ ```typescript
45
+ // router.tsx
46
+ import { createRouter } from "@rangojs/router";
47
+ import { urlpatterns } from "./urls";
48
+ import type { AppBindings, AppVars } from "./env";
49
+
50
+ export const router = createRouter<AppBindings>({}).routes(urlpatterns);
51
+
52
+ declare global {
53
+ namespace RSCRouter {
54
+ interface Env extends AppBindings {}
55
+ interface Vars extends AppVars {}
56
+ interface RegisteredRoutes extends typeof router.routeMap {}
57
+ }
58
+ }
59
+ ```
60
+
29
61
  ## Route Definition with Type-Safe Names
30
62
 
31
63
  ```typescript
@@ -95,6 +127,17 @@ function ShopNav() {
95
127
  }
96
128
  ```
97
129
 
130
+ `href()` and path-based response utilities read from `RegisteredRoutes`, so if
131
+ you want them typed globally you should augment:
132
+
133
+ ```typescript
134
+ declare global {
135
+ namespace RSCRouter {
136
+ interface RegisteredRoutes extends typeof router.routeMap {}
137
+ }
138
+ }
139
+ ```
140
+
98
141
  See `/links` for full URL generation guide.
99
142
 
100
143
  ## Environment Type Setup
@@ -128,8 +171,7 @@ import type { AppBindings, AppVariables } from "./env";
128
171
 
129
172
  const router = createRouter<AppBindings>({
130
173
  document: Document,
131
- urls: urlpatterns,
132
- });
174
+ }).routes(urlpatterns);
133
175
 
134
176
  // Register bindings and variables globally for implicit typing
135
177
  declare global {
@@ -140,19 +182,19 @@ declare global {
140
182
  }
141
183
 
142
184
  // middleware - typed via ctx.set / ctx.get
143
- import { createMiddleware } from "@rangojs/router";
185
+ import type { Middleware } from "@rangojs/router";
144
186
 
145
- export const authMiddleware = createMiddleware(async (ctx, next) => {
187
+ export const authMiddleware: Middleware = async (ctx, next) => {
146
188
  ctx.set("user", {
147
189
  id: "123",
148
190
  email: "user@example.com",
149
191
  role: "admin",
150
192
  });
151
193
  await next();
152
- });
194
+ };
153
195
 
154
196
  // loaders - typed context
155
- export const UserLoader = createLoader("user", async (ctx) => {
197
+ export const UserLoader = createLoader(async (ctx) => {
156
198
  const db = ctx.env.DB; // D1Database (plain bindings)
157
199
  const userId = ctx.get("user")?.id; // from RSCRouter.Vars
158
200
  return db.prepare("SELECT * FROM users WHERE id = ?").bind(userId).first();
@@ -177,7 +219,7 @@ Now handlers have typed context without explicit imports:
177
219
 
178
220
  ```typescript
179
221
  // In loaders
180
- export const DashboardLoader = createLoader("dashboard", async (ctx) => {
222
+ export const DashboardLoader = createLoader(async (ctx) => {
181
223
  // ctx.env.DB is typed from global RSCRouter.Env
182
224
  // ctx.get("user") is typed from global RSCRouter.Vars
183
225
  const user = ctx.get("user");
@@ -239,7 +281,7 @@ import type { RouteSearchParams, RouteParams } from "@rangojs/router";
239
281
 
240
282
  // RouteSearchParams<"name"> resolves the search schema to a typed object
241
283
  type SP = RouteSearchParams<"search">;
242
- // { q: string; page?: number; sort?: string }
284
+ // { q: string | undefined; page?: number; sort?: string }
243
285
 
244
286
  // RouteParams<"name"> resolves URL params from the route pattern
245
287
  type P = RouteParams<"blogPost">;
@@ -283,7 +325,7 @@ Loaders have typed return values:
283
325
 
284
326
  ```typescript
285
327
  // loaders/product.ts
286
- export const ProductLoader = createLoader("product", async (ctx) => {
328
+ export const ProductLoader = createLoader(async (ctx) => {
287
329
  return {
288
330
  id: ctx.params.slug,
289
331
  name: "Widget",
@@ -292,7 +334,7 @@ export const ProductLoader = createLoader("product", async (ctx) => {
292
334
  });
293
335
 
294
336
  // In server component - type is inferred
295
- import { useLoader } from "@rangojs/router";
337
+ import { useLoader } from "@rangojs/router/client";
296
338
 
297
339
  async function ProductPage() {
298
340
  const product = await useLoader(ProductLoader);
@@ -302,11 +344,12 @@ async function ProductPage() {
302
344
 
303
345
  // In client component - same type
304
346
  "use client";
305
- import { useLoaderData } from "@rangojs/router/client";
347
+ import { useLoader } from "@rangojs/router/client";
306
348
 
307
349
  function ProductPrice() {
308
- const { product } = useLoaderData(ProductLoader);
309
- // product: { id: string; name: string; price: number }
350
+ const { data } = useLoader(ProductLoader);
351
+ // data: { id: string; name: string; price: number }
352
+ const product = data;
310
353
  return <span>${product.price}</span>;
311
354
  }
312
355
  ```
@@ -559,7 +602,7 @@ export default router;
559
602
  // No manual RegisteredRoutes declaration needed.
560
603
 
561
604
  // 5. loaders/*.ts - Type-safe loaders
562
- export const ProductLoader = createLoader("product", async (ctx) => {
605
+ export const ProductLoader = createLoader(async (ctx) => {
563
606
  // ctx.params: { slug: string }
564
607
  // ctx.get("user"): User | undefined (from RSCRouter.Vars)
565
608
  // ctx.env.DB: D1Database (plain bindings from RSCRouter.Env)
@@ -102,14 +102,28 @@ export async function getProductData(ctx) {
102
102
  // On hit: return value restored, breadcrumb replayed.
103
103
  ```
104
104
 
105
- ## ctx Side-Effect Guards
105
+ ## Request-Scoped Guards
106
+
107
+ ### Read Guards
108
+
109
+ `cookies()` and `headers()` **throw** inside a `"use cache"` function because
110
+ per-request values (cookies, headers) are not reflected in the cache key. Without
111
+ this guard, one user's data would be served to another.
112
+
113
+ Extract the value before the cached function and pass it as an argument:
114
+
115
+ ```typescript
116
+ const locale = cookies().get("locale")?.value ?? "en";
117
+ const data = await getCachedData(locale); // locale is now in the cache key
118
+ ```
119
+
120
+ ### Side-Effect Guards
106
121
 
107
122
  These ctx methods **throw** inside a `"use cache"` function because their effects
108
123
  are lost on cache hit (the function body is skipped):
109
124
 
110
125
  - `ctx.set()` / `ctx.get()` for passing values to children
111
126
  - `ctx.header()`
112
- - `ctx.setCookie()` / `ctx.deleteCookie()`
113
127
  - `ctx.setTheme()`
114
128
  - `ctx.setLocationState()`
115
129
  - `ctx.onResponse()`
package/src/__internal.ts CHANGED
@@ -160,7 +160,7 @@ export type {
160
160
  /**
161
161
  * @internal
162
162
  * Internal handler context with additional props for router internals.
163
- * Includes `_originalRequest` and `_currentSegmentId`.
163
+ * Includes `_currentSegmentId` and `_responseType`.
164
164
  */
165
165
  export type { InternalHandlerContext } from "./types.js";
166
166
 
package/src/bin/rango.ts CHANGED
@@ -5,6 +5,9 @@ import {
5
5
  writePerModuleRouteTypesForFile,
6
6
  writeCombinedRouteTypes,
7
7
  detectUnresolvableIncludes,
8
+ detectUnresolvableIncludesForUrlsFile,
9
+ findNestedRouterConflict,
10
+ formatNestedRouterConflictError,
8
11
  type UnresolvableInclude,
9
12
  } from "../build/generate-route-types.ts";
10
13
 
@@ -131,22 +134,18 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
131
134
  process.exit(0);
132
135
  }
133
136
 
137
+ // Phase 1: Classify files
134
138
  const routerFiles: string[] = [];
139
+ const urlsFiles: string[] = [];
135
140
 
136
141
  for (const filePath of files) {
137
142
  try {
138
143
  const source = readFileSync(filePath, "utf-8");
139
-
140
- // Detect file type and generate accordingly
141
- const isRouter = /\bcreateRouter\s*[<(]/.test(source);
142
- const isUrls = source.includes("urls(");
143
-
144
- if (isRouter) {
144
+ if (/\bcreateRouter\s*[<(]/.test(source)) {
145
145
  routerFiles.push(filePath);
146
146
  }
147
-
148
- if (isUrls) {
149
- writePerModuleRouteTypesForFile(filePath);
147
+ if (source.includes("urls(")) {
148
+ urlsFiles.push(filePath);
150
149
  }
151
150
  } catch (err) {
152
151
  console.warn(
@@ -155,9 +154,10 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
155
154
  }
156
155
  }
157
156
 
158
- // Check for unresolvable includes across all router files
157
+ // Phase 2: Collect diagnostics from all files BEFORE writing anything
159
158
  const allDiagnostics: Array<UnresolvableInclude & { routerFile: string }> =
160
159
  [];
160
+
161
161
  for (const routerFile of routerFiles) {
162
162
  const diagnostics = detectUnresolvableIncludes(routerFile);
163
163
  for (const d of diagnostics) {
@@ -165,10 +165,29 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
165
165
  }
166
166
  }
167
167
 
168
- if (allDiagnostics.length > 0 && mode === "default") {
169
- // Hard error: unresolvable includes detected
168
+ // Also check standalone urls files not covered by router-level detection
169
+ const routerFileSet = new Set(routerFiles);
170
+ for (const urlsFile of urlsFiles) {
171
+ if (routerFileSet.has(urlsFile)) continue;
172
+ const diagnostics = detectUnresolvableIncludesForUrlsFile(urlsFile);
173
+ for (const d of diagnostics) {
174
+ allDiagnostics.push({ ...d, routerFile: urlsFile });
175
+ }
176
+ }
177
+
178
+ // Deduplicate diagnostics (router and urls detection may find the same issue)
179
+ const seen = new Set<string>();
180
+ const uniqueDiagnostics = allDiagnostics.filter((d) => {
181
+ const key = `${d.sourceFile}:${d.pathPrefix}:${d.reason}`;
182
+ if (seen.has(key)) return false;
183
+ seen.add(key);
184
+ return true;
185
+ });
186
+
187
+ if (uniqueDiagnostics.length > 0 && mode === "default") {
188
+ // Hard error: no files written
170
189
  console.error("\n[rango] Unresolvable includes detected:\n");
171
- formatDiagnostics(allDiagnostics);
190
+ formatDiagnostics(uniqueDiagnostics);
172
191
  console.error(
173
192
  "\nThe static parser cannot resolve these includes because they use " +
174
193
  "factory functions or dynamic expressions.\n\n" +
@@ -179,16 +198,28 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
179
198
  process.exit(1);
180
199
  }
181
200
 
182
- if (allDiagnostics.length > 0 && mode === "static") {
201
+ if (uniqueDiagnostics.length > 0 && mode === "static") {
183
202
  // Warning: partial output accepted
184
203
  console.warn(
185
204
  "\n[rango] Warning: partial output (unresolvable includes):\n",
186
205
  );
187
- formatDiagnostics(allDiagnostics);
206
+ formatDiagnostics(uniqueDiagnostics);
188
207
  console.warn("");
189
208
  }
190
209
 
191
- // Generate named-routes for any detected router files
210
+ const nestedRouterConflict = findNestedRouterConflict(routerFiles);
211
+ if (nestedRouterConflict) {
212
+ console.error(
213
+ `\n${formatNestedRouterConflictError(nestedRouterConflict, "[rango]")}\n`,
214
+ );
215
+ process.exit(1);
216
+ }
217
+
218
+ // Phase 3: Write all outputs (only reached if diagnostics pass or --static)
219
+ for (const urlsFile of urlsFiles) {
220
+ writePerModuleRouteTypesForFile(urlsFile);
221
+ }
222
+
192
223
  for (const routerFile of routerFiles) {
193
224
  const projectRoot = findProjectRoot(routerFile);
194
225
  writeCombinedRouteTypes(projectRoot, [routerFile]);
@@ -238,6 +269,14 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
238
269
  process.exit(1);
239
270
  }
240
271
 
272
+ const nestedRouterConflict = findNestedRouterConflict(routerEntries);
273
+ if (nestedRouterConflict) {
274
+ console.error(
275
+ `\n${formatNestedRouterConflictError(nestedRouterConflict, "[rango]")}\n`,
276
+ );
277
+ process.exit(1);
278
+ }
279
+
241
280
  let discoverAndWriteRouteTypes: typeof import("../build/runtime-discovery.ts").discoverAndWriteRouteTypes;
242
281
  try {
243
282
  const mod = await import("../build/runtime-discovery.ts");
@@ -257,10 +296,8 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
257
296
  process.exit(1);
258
297
  }
259
298
 
260
- // Use a single project root for all routers (find from the first entry)
261
- const projectRoot = findProjectRoot(routerEntries[0]);
262
-
263
299
  for (const entry of routerEntries) {
300
+ const projectRoot = findProjectRoot(entry);
264
301
  const result = await discoverAndWriteRouteTypes({
265
302
  root: projectRoot,
266
303
  configFile,
@@ -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 };
@@ -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") {
@@ -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
  }
@@ -189,6 +182,7 @@ export interface EventController {
189
182
  // State access
190
183
  getState(): DerivedNavigationState;
191
184
  getActionState(actionId: string): TrackedActionState;
185
+ getLocation(): NavigationLocation;
192
186
 
193
187
  // Location updates (for popstate where navigation doesn't go through startNavigation)
194
188
  setLocation(location: NavigationLocation): void;
@@ -216,6 +210,8 @@ export interface EventController {
216
210
  // Direct state access for advanced use
217
211
  getCurrentNavigation(): NavigationEntry | null;
218
212
  getInflightActions(): Map<string, ActionEntry>;
213
+ /** Whether any concurrent actions have occurred (shared across all handles) */
214
+ hadAnyConcurrentActions(): boolean;
219
215
  }
220
216
 
221
217
  // ============================================================================
@@ -394,8 +390,8 @@ export function createEventController(
394
390
  state,
395
391
  isStreaming,
396
392
  location,
397
- // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending
398
- // Background revalidations don't expose a pending URL
393
+ // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
394
+ // Background revalidations (skipLoadingState) don't expose a pending URL.
399
395
  pendingUrl:
400
396
  currentNavigation?.phase === "fetching" &&
401
397
  !currentNavigation.options?.skipLoadingState
@@ -488,6 +484,7 @@ export function createEventController(
488
484
 
489
485
  startStreaming(): StreamingToken {
490
486
  let ended = false;
487
+ entry.phase = "streaming";
491
488
  activeStreamCount++;
492
489
  notify();
493
490
 
@@ -675,24 +672,8 @@ export function createEventController(
675
672
  // If streaming is in progress, tryFinalize() will be called when streaming ends
676
673
  },
677
674
 
678
- getConsolidationSegments(): string[] | null {
679
- // Only consolidate if all actions have at least received their response
680
- // We don't need to wait for streaming to complete since we're refetching anyway
681
- // Count actions that are still fetching (waiting for server response)
682
- const stillFetchingCount = [...inflightActions.values()].filter(
683
- (a) => a.phase === "fetching",
684
- ).length;
685
-
686
- if (stillFetchingCount > 0) {
687
- return null; // Some actions still waiting for server response
688
- }
689
- if (!hadAnyConcurrentActions) {
690
- return null; // No concurrent actions occurred
691
- }
692
- if (concurrentRevalidatedSegments.size === 0) {
693
- return null; // No segments to consolidate
694
- }
695
- return Array.from(concurrentRevalidatedSegments);
675
+ getRevalidatedSegments(): Set<string> {
676
+ return concurrentRevalidatedSegments;
696
677
  },
697
678
 
698
679
  clearConsolidation() {
@@ -727,16 +708,26 @@ export function createEventController(
727
708
  }
728
709
 
729
710
  function abortAllActions() {
730
- 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;
731
717
  entry.abort.abort();
718
+ inflightActions.delete(id);
732
719
  }
733
- inflightActions.clear();
734
720
  hadAnyConcurrentActions = false;
735
721
  concurrentRevalidatedSegments.clear();
736
722
  notify();
737
- // Notify all action listeners
738
- for (const actionId of actionListeners.keys()) {
739
- 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));
740
731
  }
741
732
  }
742
733
 
@@ -744,18 +735,6 @@ export function createEventController(
744
735
  // Handle Operations
745
736
  // ========================================================================
746
737
 
747
- /**
748
- * Filter segment IDs to only include routes and layouts.
749
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
750
- */
751
- function filterSegmentOrder(matched: string[]): string[] {
752
- return matched.filter((id) => {
753
- if (id.includes(".@")) return false;
754
- if (/D\d+\./.test(id)) return false;
755
- return true;
756
- });
757
- }
758
-
759
738
  function setHandleData(
760
739
  data: HandleData,
761
740
  matched?: string[],
@@ -859,6 +838,7 @@ export function createEventController(
859
838
  // State
860
839
  getState,
861
840
  getActionState,
841
+ getLocation: () => location,
862
842
  setLocation,
863
843
 
864
844
  // Handles
@@ -877,6 +857,7 @@ export function createEventController(
877
857
  // Direct access
878
858
  getCurrentNavigation: () => currentNavigation,
879
859
  getInflightActions: () => inflightActions,
860
+ hadAnyConcurrentActions: () => hadAnyConcurrentActions,
880
861
  };
881
862
  }
882
863
 
@@ -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
+ }