@rangojs/router 0.0.0-experimental.97 → 0.0.0-experimental.98914650

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 (356) hide show
  1. package/README.md +24 -9
  2. package/dist/bin/rango.js +157 -63
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +1584 -639
  5. package/package.json +71 -21
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +60 -0
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +222 -30
  10. package/skills/caching/SKILL.md +263 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +235 -28
  16. package/skills/host-router/SKILL.md +122 -22
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +29 -5
  19. package/skills/layout/SKILL.md +13 -9
  20. package/skills/links/SKILL.md +173 -17
  21. package/skills/loader/SKILL.md +170 -23
  22. package/skills/middleware/SKILL.md +16 -10
  23. package/skills/migrate-nextjs/SKILL.md +38 -16
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +11 -7
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +250 -25
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +114 -47
  31. package/skills/route/SKILL.md +42 -5
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +78 -42
  34. package/skills/tailwind/SKILL.md +27 -3
  35. package/skills/testing/SKILL.md +129 -0
  36. package/skills/testing/bindings.md +89 -0
  37. package/skills/testing/cache-prerender.md +124 -0
  38. package/skills/testing/client-components.md +122 -0
  39. package/skills/testing/e2e-parity.md +125 -0
  40. package/skills/testing/flight.md +92 -0
  41. package/skills/testing/handles.md +129 -0
  42. package/skills/testing/loader.md +128 -0
  43. package/skills/testing/middleware.md +99 -0
  44. package/skills/testing/render-handler.md +121 -0
  45. package/skills/testing/response-routes.md +95 -0
  46. package/skills/testing/reverse-and-types.md +84 -0
  47. package/skills/testing/server-actions.md +107 -0
  48. package/skills/testing/server-tree.md +128 -0
  49. package/skills/testing/setup.md +120 -0
  50. package/skills/typesafety/SKILL.md +316 -26
  51. package/skills/use-cache/SKILL.md +36 -5
  52. package/skills/vercel/SKILL.md +107 -0
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/__internal.ts +0 -65
  57. package/src/browser/action-coordinator.ts +53 -36
  58. package/src/browser/action-fence.ts +47 -0
  59. package/src/browser/app-shell.ts +14 -27
  60. package/src/browser/cookie-name.ts +140 -0
  61. package/src/browser/event-controller.ts +37 -143
  62. package/src/browser/history-state.ts +21 -0
  63. package/src/browser/index.ts +3 -3
  64. package/src/browser/invalidate-client-cache.ts +52 -0
  65. package/src/browser/navigation-bridge.ts +30 -59
  66. package/src/browser/navigation-client.ts +96 -84
  67. package/src/browser/navigation-store-handle.ts +38 -0
  68. package/src/browser/navigation-store.ts +32 -82
  69. package/src/browser/navigation-transaction.ts +9 -59
  70. package/src/browser/partial-update.ts +60 -127
  71. package/src/browser/prefetch/cache.ts +82 -72
  72. package/src/browser/prefetch/fetch.ts +108 -33
  73. package/src/browser/prefetch/queue.ts +6 -3
  74. package/src/browser/rango-state.ts +157 -115
  75. package/src/browser/react/Link.tsx +0 -2
  76. package/src/browser/react/NavigationProvider.tsx +41 -48
  77. package/src/browser/react/ScrollRestoration.tsx +10 -6
  78. package/src/browser/react/filter-segment-order.ts +0 -2
  79. package/src/browser/react/index.ts +0 -48
  80. package/src/browser/react/location-state-shared.ts +166 -8
  81. package/src/browser/react/location-state.ts +39 -14
  82. package/src/browser/react/use-action.ts +6 -15
  83. package/src/browser/react/use-handle.ts +17 -14
  84. package/src/browser/react/use-link-status.ts +0 -4
  85. package/src/browser/react/use-navigation.ts +0 -3
  86. package/src/browser/react/use-params.ts +11 -11
  87. package/src/browser/react/use-reverse.ts +106 -0
  88. package/src/browser/react/use-router.ts +20 -5
  89. package/src/browser/react/use-search-params.ts +0 -5
  90. package/src/browser/react/use-segments.ts +0 -13
  91. package/src/browser/response-adapter.ts +52 -1
  92. package/src/browser/rsc-router.tsx +70 -34
  93. package/src/browser/scroll-restoration.ts +22 -14
  94. package/src/browser/segment-structure-assert.ts +2 -2
  95. package/src/browser/server-action-bridge.ts +168 -44
  96. package/src/browser/types.ts +36 -21
  97. package/src/browser/validate-redirect-origin.ts +43 -16
  98. package/src/build/collect-fallback-refs.ts +107 -0
  99. package/src/build/generate-manifest.ts +60 -35
  100. package/src/build/generate-route-types.ts +3 -0
  101. package/src/build/index.ts +8 -2
  102. package/src/build/prefix-tree-utils.ts +123 -0
  103. package/src/build/route-trie.ts +89 -10
  104. package/src/build/route-types/codegen.ts +4 -4
  105. package/src/build/route-types/include-resolution.ts +1 -1
  106. package/src/build/route-types/param-extraction.ts +6 -3
  107. package/src/build/route-types/per-module-writer.ts +7 -4
  108. package/src/build/route-types/router-processing.ts +122 -22
  109. package/src/build/route-types/scan-filter.ts +1 -1
  110. package/src/build/route-types/source-scan.ts +118 -0
  111. package/src/build/runtime-discovery.ts +9 -20
  112. package/src/cache/cache-error.ts +104 -0
  113. package/src/cache/cache-policy.ts +68 -28
  114. package/src/cache/cache-runtime.ts +134 -32
  115. package/src/cache/cache-scope.ts +100 -74
  116. package/src/cache/cache-tag.ts +98 -0
  117. package/src/cache/cf/cf-cache-store.ts +2255 -238
  118. package/src/cache/cf/index.ts +6 -16
  119. package/src/cache/document-cache.ts +61 -20
  120. package/src/cache/handle-snapshot.ts +63 -0
  121. package/src/cache/index.ts +22 -20
  122. package/src/cache/memory-segment-store.ts +136 -37
  123. package/src/cache/profile-registry.ts +6 -30
  124. package/src/cache/read-through-swr.ts +41 -11
  125. package/src/cache/segment-codec.ts +0 -16
  126. package/src/cache/tag-invalidation.ts +230 -0
  127. package/src/cache/types.ts +33 -100
  128. package/src/cache/vercel/index.ts +11 -0
  129. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  130. package/src/client.rsc.tsx +6 -21
  131. package/src/client.tsx +25 -61
  132. package/src/component-utils.ts +19 -0
  133. package/src/context-var.ts +17 -5
  134. package/src/decode-loader-results.ts +36 -0
  135. package/src/defer.ts +196 -0
  136. package/src/deps/ssr.ts +0 -1
  137. package/src/errors.ts +30 -4
  138. package/src/handle.ts +31 -23
  139. package/src/handles/MetaTags.tsx +0 -14
  140. package/src/handles/breadcrumbs.ts +16 -5
  141. package/src/handles/meta.ts +0 -39
  142. package/src/host/cookie-handler.ts +0 -36
  143. package/src/host/errors.ts +0 -24
  144. package/src/host/index.ts +8 -2
  145. package/src/host/pattern-matcher.ts +7 -50
  146. package/src/host/router.ts +107 -99
  147. package/src/host/testing.ts +40 -27
  148. package/src/host/types.ts +37 -4
  149. package/src/host/utils.ts +1 -1
  150. package/src/href-client.ts +137 -22
  151. package/src/index.rsc.ts +63 -9
  152. package/src/index.ts +64 -9
  153. package/src/internal-debug.ts +2 -4
  154. package/src/loader-store.ts +500 -0
  155. package/src/loader.rsc.ts +20 -13
  156. package/src/loader.ts +12 -11
  157. package/src/missing-id-error.ts +68 -0
  158. package/src/network-error-thrower.tsx +1 -6
  159. package/src/outlet-provider.tsx +1 -5
  160. package/src/prerender/param-hash.ts +10 -11
  161. package/src/prerender/store.ts +32 -37
  162. package/src/prerender.ts +61 -6
  163. package/src/redirect-origin.ts +100 -0
  164. package/src/response-utils.ts +9 -0
  165. package/src/reverse.ts +65 -40
  166. package/src/root-error-boundary.tsx +1 -19
  167. package/src/route-content-wrapper.tsx +7 -72
  168. package/src/route-definition/dsl-helpers.ts +244 -281
  169. package/src/route-definition/helper-factories.ts +29 -139
  170. package/src/route-definition/helpers-types.ts +40 -17
  171. package/src/route-definition/redirect.ts +43 -9
  172. package/src/route-definition/resolve-handler-use.ts +6 -0
  173. package/src/route-definition/use-item-types.ts +32 -0
  174. package/src/route-map-builder.ts +0 -16
  175. package/src/route-types.ts +19 -41
  176. package/src/router/basename.ts +14 -0
  177. package/src/router/content-negotiation.ts +15 -15
  178. package/src/router/error-handling.ts +13 -17
  179. package/src/router/find-match.ts +44 -23
  180. package/src/router/handler-context.ts +4 -41
  181. package/src/router/intercept-resolution.ts +14 -19
  182. package/src/router/lazy-includes.ts +9 -46
  183. package/src/router/loader-resolution.ts +91 -46
  184. package/src/router/logging.ts +0 -6
  185. package/src/router/manifest.ts +18 -29
  186. package/src/router/match-api.ts +0 -20
  187. package/src/router/match-context.ts +0 -22
  188. package/src/router/match-handlers.ts +57 -58
  189. package/src/router/match-middleware/background-revalidation.ts +0 -7
  190. package/src/router/match-middleware/cache-lookup.ts +150 -271
  191. package/src/router/match-middleware/cache-store.ts +3 -33
  192. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  193. package/src/router/match-middleware/segment-resolution.ts +0 -22
  194. package/src/router/match-pipelines.ts +1 -42
  195. package/src/router/match-result.ts +31 -80
  196. package/src/router/metrics.ts +0 -34
  197. package/src/router/middleware-types.ts +5 -112
  198. package/src/router/middleware.ts +118 -133
  199. package/src/router/navigation-snapshot.ts +0 -51
  200. package/src/router/params-util.ts +23 -0
  201. package/src/router/pattern-matching.ts +62 -67
  202. package/src/router/prerender-match.ts +99 -63
  203. package/src/router/preview-match.ts +3 -1
  204. package/src/router/request-classification.ts +28 -62
  205. package/src/router/revalidation.ts +50 -56
  206. package/src/router/route-snapshot.ts +0 -1
  207. package/src/router/router-context.ts +0 -27
  208. package/src/router/router-interfaces.ts +68 -35
  209. package/src/router/router-options.ts +55 -1
  210. package/src/router/router-registry.ts +2 -5
  211. package/src/router/segment-resolution/fresh.ts +44 -63
  212. package/src/router/segment-resolution/helpers.ts +34 -0
  213. package/src/router/segment-resolution/loader-cache.ts +40 -37
  214. package/src/router/segment-resolution/revalidation.ts +203 -285
  215. package/src/router/segment-resolution/static-store.ts +19 -5
  216. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  217. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  218. package/src/router/segment-resolution.ts +4 -1
  219. package/src/router/segment-wrappers.ts +0 -3
  220. package/src/router/state-cookie-name.ts +33 -0
  221. package/src/router/substitute-pattern-params.ts +56 -0
  222. package/src/router/telemetry-otel.ts +0 -20
  223. package/src/router/telemetry.ts +96 -19
  224. package/src/router/timeout.ts +0 -20
  225. package/src/router/trie-matching.ts +87 -48
  226. package/src/router/types.ts +9 -63
  227. package/src/router/url-params.ts +0 -5
  228. package/src/router.ts +80 -41
  229. package/src/rsc/handler-context.ts +3 -2
  230. package/src/rsc/handler.ts +83 -78
  231. package/src/rsc/helpers.ts +93 -5
  232. package/src/rsc/index.ts +1 -1
  233. package/src/rsc/json-route-result.ts +38 -0
  234. package/src/rsc/manifest-init.ts +28 -41
  235. package/src/rsc/origin-guard.ts +39 -25
  236. package/src/rsc/progressive-enhancement.ts +12 -1
  237. package/src/rsc/redirect-guard.ts +99 -0
  238. package/src/rsc/response-error.ts +79 -12
  239. package/src/rsc/response-route-handler.ts +76 -62
  240. package/src/rsc/rsc-rendering.ts +41 -60
  241. package/src/rsc/runtime-warnings.ts +23 -10
  242. package/src/rsc/server-action.ts +62 -67
  243. package/src/rsc/ssr-setup.ts +16 -0
  244. package/src/rsc/types.ts +10 -5
  245. package/src/runtime-env.ts +18 -0
  246. package/src/search-params.ts +4 -20
  247. package/src/segment-loader-promise.ts +14 -2
  248. package/src/segment-system.tsx +199 -142
  249. package/src/serialize.ts +243 -0
  250. package/src/server/context.ts +150 -51
  251. package/src/server/cookie-store.ts +80 -5
  252. package/src/server/handle-store.ts +7 -24
  253. package/src/server/loader-registry.ts +5 -24
  254. package/src/server/request-context.ts +165 -87
  255. package/src/ssr/index.tsx +14 -14
  256. package/src/static-handler.ts +10 -13
  257. package/src/testing/cache-status.ts +162 -0
  258. package/src/testing/collect-handle.ts +40 -0
  259. package/src/testing/dispatch.ts +618 -0
  260. package/src/testing/dom.entry.ts +22 -0
  261. package/src/testing/e2e/fixture.ts +188 -0
  262. package/src/testing/e2e/index.ts +128 -0
  263. package/src/testing/e2e/matchers.ts +35 -0
  264. package/src/testing/e2e/page-helpers.ts +272 -0
  265. package/src/testing/e2e/parity.ts +387 -0
  266. package/src/testing/e2e/server.ts +195 -0
  267. package/src/testing/flight-matchers.ts +97 -0
  268. package/src/testing/flight-normalize.ts +11 -0
  269. package/src/testing/flight-runtime.d.ts +57 -0
  270. package/src/testing/flight-tree.ts +682 -0
  271. package/src/testing/flight.entry.ts +52 -0
  272. package/src/testing/flight.ts +232 -0
  273. package/src/testing/generated-routes.ts +183 -0
  274. package/src/testing/index.ts +99 -0
  275. package/src/testing/internal/context.ts +348 -0
  276. package/src/testing/internal/flight-client-globals.ts +30 -0
  277. package/src/testing/internal/seed-vars.ts +54 -0
  278. package/src/testing/render-handler.ts +330 -0
  279. package/src/testing/render-route.tsx +566 -0
  280. package/src/testing/run-loader.ts +378 -0
  281. package/src/testing/run-middleware.ts +205 -0
  282. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  283. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  284. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  285. package/src/testing/vitest-stubs/version.ts +5 -0
  286. package/src/testing/vitest.ts +305 -0
  287. package/src/theme/ThemeProvider.tsx +0 -52
  288. package/src/theme/ThemeScript.tsx +0 -6
  289. package/src/theme/constants.ts +0 -12
  290. package/src/theme/index.ts +0 -7
  291. package/src/theme/theme-context.ts +1 -5
  292. package/src/theme/theme-script.ts +0 -14
  293. package/src/theme/use-theme.ts +0 -3
  294. package/src/types/boundaries.ts +0 -35
  295. package/src/types/cache-types.ts +13 -4
  296. package/src/types/error-types.ts +30 -90
  297. package/src/types/global-namespace.ts +54 -41
  298. package/src/types/handler-context.ts +97 -22
  299. package/src/types/index.ts +1 -10
  300. package/src/types/loader-types.ts +6 -3
  301. package/src/types/request-scope.ts +0 -19
  302. package/src/types/route-config.ts +6 -50
  303. package/src/types/route-entry.ts +0 -6
  304. package/src/types/segments.ts +18 -14
  305. package/src/urls/include-helper.ts +9 -56
  306. package/src/urls/index.ts +1 -11
  307. package/src/urls/path-helper-types.ts +19 -5
  308. package/src/urls/path-helper.ts +17 -106
  309. package/src/urls/pattern-types.ts +36 -19
  310. package/src/urls/response-types.ts +20 -19
  311. package/src/urls/type-extraction.ts +58 -139
  312. package/src/urls/urls-function.ts +1 -18
  313. package/src/use-loader.tsx +292 -107
  314. package/src/vite/debug.ts +1 -0
  315. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  316. package/src/vite/discovery/discover-routers.ts +95 -82
  317. package/src/vite/discovery/discovery-errors.ts +194 -0
  318. package/src/vite/discovery/prerender-collection.ts +26 -34
  319. package/src/vite/discovery/route-types-writer.ts +40 -84
  320. package/src/vite/discovery/state.ts +39 -1
  321. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  322. package/src/vite/index.ts +4 -0
  323. package/src/vite/plugin-types.ts +185 -10
  324. package/src/vite/plugins/cjs-to-esm.ts +3 -18
  325. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  326. package/src/vite/plugins/client-ref-hashing.ts +12 -11
  327. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  328. package/src/vite/plugins/expose-action-id.ts +4 -75
  329. package/src/vite/plugins/expose-id-utils.ts +3 -54
  330. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  331. package/src/vite/plugins/expose-ids/handler-transform.ts +6 -74
  332. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  333. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  334. package/src/vite/plugins/expose-internal-ids.ts +57 -67
  335. package/src/vite/plugins/performance-tracks.ts +9 -16
  336. package/src/vite/plugins/refresh-cmd.ts +1 -1
  337. package/src/vite/plugins/use-cache-transform.ts +26 -49
  338. package/src/vite/plugins/vercel-output.ts +258 -0
  339. package/src/vite/plugins/version-injector.ts +2 -32
  340. package/src/vite/plugins/version-plugin.ts +32 -23
  341. package/src/vite/plugins/virtual-entries.ts +35 -17
  342. package/src/vite/rango.ts +148 -115
  343. package/src/vite/router-discovery.ts +220 -68
  344. package/src/vite/utils/ast-handler-extract.ts +15 -31
  345. package/src/vite/utils/bundle-analysis.ts +10 -15
  346. package/src/vite/utils/client-chunks.ts +184 -0
  347. package/src/vite/utils/forward-user-plugins.ts +171 -0
  348. package/src/vite/utils/manifest-utils.ts +4 -59
  349. package/src/vite/utils/package-resolution.ts +1 -73
  350. package/src/vite/utils/prerender-utils.ts +0 -34
  351. package/src/vite/utils/shared-utils.ts +95 -43
  352. package/src/browser/action-response-classifier.ts +0 -99
  353. package/src/browser/react/use-client-cache.ts +0 -58
  354. package/src/browser/shallow.ts +0 -40
  355. package/src/handles/index.ts +0 -7
  356. package/src/router/middleware-cookies.ts +0 -55
@@ -1,9 +1,21 @@
1
- import {
2
- classifyActionResponse,
3
- type ActionScenario,
4
- } from "./action-response-classifier.js";
5
1
  import type { ActionEntry } from "./event-controller.js";
6
2
 
3
+ /**
4
+ * Post-reconciliation action outcome (discriminated union). Error and
5
+ * full-update-unsupported cases are handled inline in the bridge before
6
+ * reconciliation; this only covers successfully-reconciled partial responses.
7
+ */
8
+ 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
+
7
19
  /**
8
20
  * Plain data inputs for classifying a post-reconciliation action outcome.
9
21
  * No browser objects or controller references — all values are snapshots.
@@ -33,20 +45,15 @@ export interface ActionOutcomeInput {
33
45
  currentInterceptSource: string | null;
34
46
  }
35
47
 
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
- */
48
+ // Segment IDs to re-fetch when concurrent actions each revalidated different
49
+ // parts of the tree; null when consolidation does not apply. Returns null while
50
+ // any action is still fetching — consolidation must wait for all to land.
43
51
  function computeConsolidationSegments(
44
52
  input: ActionOutcomeInput,
45
53
  ): string[] | null {
46
54
  if (!input.hadAnyConcurrentActions) return null;
47
55
  if (input.revalidatedSegments.size === 0) return null;
48
56
 
49
- // Can't consolidate while any action is still waiting for a server response
50
57
  const stillFetchingCount = [...input.inflightActions.values()].filter(
51
58
  (a) => a.phase === "fetching",
52
59
  ).length;
@@ -55,9 +62,6 @@ function computeConsolidationSegments(
55
62
  return Array.from(input.revalidatedSegments);
56
63
  }
57
64
 
58
- /**
59
- * Count other actions still in "fetching" phase (excluding this handle).
60
- */
61
65
  function countOtherFetchingActions(input: ActionOutcomeInput): number {
62
66
  let count = 0;
63
67
  for (const [, a] of input.inflightActions) {
@@ -69,29 +73,42 @@ function countOtherFetchingActions(input: ActionOutcomeInput): number {
69
73
  }
70
74
 
71
75
  /**
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.
76
+ * Classify a post-reconciliation action outcome. Ordered priority chain: each
77
+ * case assumes the earlier ones are false (e.g. concurrent-skip only applies on
78
+ * the still-current route, consolidation only once no action is still fetching).
79
+ * The bridge calls this to decide whether to render, skip, consolidate, or refetch.
80
80
  */
81
81
  export function classifyActionOutcome(
82
82
  input: ActionOutcomeInput,
83
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
- }
84
+ if (
85
+ input.currentPathname !== input.actionStartPathname ||
86
+ input.currentLocationKey !== input.actionStartLocationKey
87
+ ) {
88
+ return {
89
+ type: "navigated-away",
90
+ historyKeyChanged:
91
+ input.currentLocationKey !== input.actionStartLocationKey,
92
+ onInterceptRoute: input.currentInterceptSource !== null,
93
+ };
94
+ }
95
+
96
+ if (input.reconciledSegmentCount < input.matchedCount) {
97
+ return { type: "hmr-missing" };
98
+ }
99
+
100
+ const consolidationSegments = computeConsolidationSegments(input);
101
+ if (consolidationSegments && consolidationSegments.length > 0) {
102
+ return { type: "consolidation-needed", segmentIds: consolidationSegments };
103
+ }
104
+
105
+ const otherFetchingActionCount = countOtherFetchingActions(input);
106
+ if (otherFetchingActionCount > 0) {
107
+ return {
108
+ type: "concurrent-skip",
109
+ otherFetchingCount: otherFetchingActionCount,
110
+ };
111
+ }
96
112
 
97
- export type { ActionScenario };
113
+ return { type: "normal" };
114
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Action fence: a refcounted flag that is raised while a server action is in
3
+ * flight and lowered when it resolves.
4
+ *
5
+ * It replaces the eager cache clear the action bridge used to do at action
6
+ * start. While the fence is up, the decision of whether the action invalidated
7
+ * anything is deferred to the response:
8
+ *
9
+ * - prefetch consumption is suspended (a queued prefetch result is not served),
10
+ * - a genuine navigation during the flight fetches with `cache: "no-store"` so
11
+ * it cannot be served stale bytes from the browser's Vary-keyed HTTP cache,
12
+ * - popstate reads are treated as stale-while-revalidate.
13
+ *
14
+ * Nothing is wiped, rotated, or broadcast while the fence is up — that is what
15
+ * keeps a sibling tab from seeing a pre-commit signal. Refcounted so concurrent
16
+ * actions compose: each action raises and lowers its own reference, and the
17
+ * fence is down only when the count reaches zero.
18
+ *
19
+ * The refcount is a single module-level counter, not keyed by routerId.
20
+ * Consumers (navigation-client.ts, prefetch/fetch.ts, navigation-bridge.ts)
21
+ * read it unscoped, so an action in one router would suppress another router's
22
+ * navigation/prefetch caches. This is correct only because two routers cannot
23
+ * coexist in one live document: an SPA navigation crossing a host-router
24
+ * boundary forces a full document reload (src/router/request-classification.ts
25
+ * app-switch terminal), so there is always exactly one live router per
26
+ * document. A future multi-router-in-one-document feature must not silently
27
+ * inherit this global, cross-router cache suppression.
28
+ */
29
+
30
+ let fenceCount = 0;
31
+
32
+ export function enterActionFence(): void {
33
+ fenceCount++;
34
+ }
35
+
36
+ export function exitActionFence(): void {
37
+ if (fenceCount > 0) fenceCount--;
38
+ }
39
+
40
+ export function isActionFenceActive(): boolean {
41
+ return fenceCount > 0;
42
+ }
43
+
44
+ /** Test-only: reset the refcount between cases. */
45
+ export function __resetActionFence(): void {
46
+ fenceCount = 0;
47
+ }
@@ -1,26 +1,17 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
 
3
3
  /**
4
- * App-shell metadata: the set of per-router fields that describe the
5
- * "envelope" around the current app's segment tree. These fields are set
6
- * from the initial RSC payload and must be replaced atomically when the
7
- * client navigates into a different router (app switch).
4
+ * App-shell metadata: the per-router fields describing the "envelope" around
5
+ * the current app's segment tree the rootLayout (Document), basename,
6
+ * version, and router identity. Set once from the initial RSC payload and read
7
+ * through the AppShellRef when rendering segments.
8
8
  *
9
- * Intentionally NOT part of the shell (all document-lifetime):
10
- * - themeConfig / initialTheme: ThemeProvider is mounted above the segment
11
- * tree and must not remount on smooth transitions.
12
- * - warmupEnabled: attached to the NavigationProvider's lifetime effect;
13
- * toggling it mid-session would tear down and restart idle listeners.
14
- * Also not serialized on every full-render path (e.g. the not-found
15
- * fallback), so carrying it here would be unreliable.
16
- * - prefetchCacheTTL: the not-found full-render payload does not serialize
17
- * it, so a cross-app nav into a 404 would silently erase the setting.
18
- * Mutable shell fields must be serialized on EVERY full-render path,
19
- * otherwise absent fields are indistinguishable from "new app has no
20
- * value" and the old app's value is dropped.
21
- *
22
- * A new document navigation (hard reload) applies these fields from the
23
- * target app's initial payload.
9
+ * This is a per-document value. A navigation that crosses a host-router app
10
+ * boundary is a full document load (the server returns X-RSC-Reload for it;
11
+ * see request-classification.ts, mode "app-switch"), so the target app's shell
12
+ * along with everything else document-level (theme, warmup, prefetch-TTL) —
13
+ * is established fresh by the target app's own load. The shell is never swapped
14
+ * in place within a session.
24
15
  */
25
16
  export interface AppShell {
26
17
  /** Router identity. Used to namespace per-app client state (e.g. the
@@ -33,20 +24,16 @@ export interface AppShell {
33
24
  }
34
25
 
35
26
  /**
36
- * Mutable container for the active app shell. Read-through via `get()` so
37
- * closures capture the ref, not the shell, and pick up updates at call time.
27
+ * Container for the active app shell. Read-through via `get()` so closures
28
+ * (e.g. the segment renderer) capture the ref and read the shell at call time
29
+ * rather than closing over a stale snapshot.
38
30
  */
39
31
  export interface AppShellRef {
40
32
  get(): AppShell;
41
- update(next: AppShell): void;
42
33
  }
43
34
 
44
35
  export function createAppShellRef(initial: AppShell): AppShellRef {
45
- let current = initial;
46
36
  return {
47
- get: () => current,
48
- update: (next) => {
49
- current = next;
50
- },
37
+ get: () => initial,
51
38
  };
52
39
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Rango state cookie: value codec and attribute serialization.
3
+ *
4
+ * Shared by the client (the document.cookie writer in rango-state.ts) and the
5
+ * server (the Set-Cookie writer in invalidateClientCache). Environment-agnostic:
6
+ * no window/document access and no name composition.
7
+ *
8
+ * Name RESOLUTION deliberately lives elsewhere (router init, see
9
+ * router/state-cookie-name.ts). Keeping composition out of this shared module
10
+ * is what lets the client read the server-resolved name verbatim and compose
11
+ * nothing.
12
+ */
13
+
14
+ /** Default prefix when `stateCookiePrefix` is unset or empty after sanitization. */
15
+ export const DEFAULT_STATE_COOKIE_PREFIX = "rango-state";
16
+
17
+ /** Internal response header carrying the keepClientCache() directive. */
18
+ export const KEEP_CACHE_HEADER = "x-rango-keep-cache";
19
+
20
+ // Per-client signal headers (lower-case) that a SHARED response cache must never
21
+ // store or replay (Finding #3): a `Set-Cookie` (rango state rotation, or any
22
+ // cookie a loader set) and the keepClientCache() directive. Strip-all-Set-Cookie
23
+ // is deliberate — a shared store can't know the resolved cookie name, and any
24
+ // per-client cookie in a cacheable document is the hazard. Single source for the
25
+ // predicate, presence check, and strip below.
26
+ const PER_CLIENT_SIGNAL_HEADERS: readonly string[] = [
27
+ "set-cookie",
28
+ KEEP_CACHE_HEADER,
29
+ ];
30
+
31
+ /** True for a per-client signal header. */
32
+ export function isPerClientSignalHeader(name: string): boolean {
33
+ return PER_CLIENT_SIGNAL_HEADERS.includes(name.toLowerCase());
34
+ }
35
+
36
+ /** True if `headers` carries any per-client signal. */
37
+ export function hasPerClientSignal(headers: Headers): boolean {
38
+ return PER_CLIENT_SIGNAL_HEADERS.some((name) => headers.has(name));
39
+ }
40
+
41
+ /** Remove every per-client signal header from `headers` in place. */
42
+ export function stripPerClientSignals(headers: Headers): void {
43
+ for (const name of PER_CLIENT_SIGNAL_HEADERS) headers.delete(name);
44
+ }
45
+
46
+ /**
47
+ * Read the raw, UNDECODED value of a single cookie from a cookie string (a
48
+ * `document.cookie` jar or a request `Cookie` header). Shared by both seats so
49
+ * the client mirror and the server monotonic-guard read the SAME jar entry —
50
+ * their agreement is the guard's contract. First match wins (the entry the
51
+ * client's mirror holds); exact name boundary via the `=`; an empty value
52
+ * (`name=`) returns null (treated as absent, not a usable prior value).
53
+ */
54
+ export function getRawCookieValue(
55
+ cookieString: string | null,
56
+ name: string,
57
+ ): string | null {
58
+ if (!cookieString) return null;
59
+ const target = name + "=";
60
+ for (const part of cookieString.split(";")) {
61
+ const trimmed = part.trim();
62
+ if (trimmed.startsWith(target)) {
63
+ const value = trimmed.slice(target.length);
64
+ return value === "" ? null : value;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Encode a state value for the wire: `encodeURIComponent(version):timestamp`.
72
+ * Only the build-derived version is encoded (it is arbitrary); the `:`
73
+ * separator and numeric timestamp stay raw, so the `{version}:{timestamp}`
74
+ * shape survives and `:` is a legal cookie-value octet.
75
+ */
76
+ export function encodeStateValue(version: string, timestamp: number): string {
77
+ return `${encodeURIComponent(version)}:${timestamp}`;
78
+ }
79
+
80
+ /** Parsed state value. `version` is decoded; `timestamp` is the raw integer. */
81
+ export interface StateValue {
82
+ version: string;
83
+ timestamp: number;
84
+ }
85
+
86
+ /**
87
+ * Decode a wire value back into `{version, timestamp}`. Returns null for a
88
+ * malformed value (no `:`, empty version, or non-numeric timestamp) so callers
89
+ * mint fresh instead of trusting garbage.
90
+ */
91
+ export function decodeStateValue(raw: string): StateValue | null {
92
+ const colon = raw.indexOf(":");
93
+ if (colon <= 0) return null;
94
+ const timestamp = Number(raw.slice(colon + 1));
95
+ if (!Number.isFinite(timestamp)) return null;
96
+ let version: string;
97
+ try {
98
+ version = decodeURIComponent(raw.slice(0, colon));
99
+ } catch {
100
+ // A malformed percent-escape (e.g. "%:1") must mint fresh, not throw —
101
+ // a thrown URIError here would 500 the server seat or fail client boot.
102
+ return null;
103
+ }
104
+ return { version, timestamp };
105
+ }
106
+
107
+ /**
108
+ * Mint a fresh state value whose timestamp is strictly greater than the previous
109
+ * one, so a re-mint inside the same millisecond (or a backward clock step) still
110
+ * differs from the current value. `prevRaw` is the current wire value (the
111
+ * client's in-memory mirror, or the server's inbound header/cookie) or null; its
112
+ * timestamp is the floor. Shared by both seats so the monotonic guard lives once.
113
+ */
114
+ export function mintStateValue(
115
+ version: string,
116
+ prevRaw: string | null,
117
+ ): string {
118
+ const prevTs = prevRaw ? (decodeStateValue(prevRaw)?.timestamp ?? 0) : 0;
119
+ const ts = Math.max(Date.now(), prevTs + 1);
120
+ return encodeStateValue(version, ts);
121
+ }
122
+
123
+ /**
124
+ * Attribute string for the state cookie. Session cookie (no Max-Age/Expires),
125
+ * Path=/ (whole app), SameSite=Lax (sent on top-level navigations), and Secure
126
+ * only on https so the document.cookie write does not silently fail on plain
127
+ * http dev. Never HttpOnly (the client reads and writes it).
128
+ */
129
+ export function stateCookieAttributes(secure: boolean): string {
130
+ return `; Path=/; SameSite=Lax${secure ? "; Secure" : ""}`;
131
+ }
132
+
133
+ /** Serialize a full `name=value` cookie string with the state attributes. */
134
+ export function serializeStateCookie(
135
+ name: string,
136
+ value: string,
137
+ secure: boolean,
138
+ ): string {
139
+ return `${name}=${value}${stateCookieAttributes(secure)}`;
140
+ }
@@ -237,10 +237,6 @@ export interface EventController {
237
237
  hadAnyConcurrentActions(): boolean;
238
238
  }
239
239
 
240
- // ============================================================================
241
- // Default States
242
- // ============================================================================
243
-
244
240
  const DEFAULT_ACTION_STATE: TrackedActionState = {
245
241
  state: "idle",
246
242
  actionId: null,
@@ -261,20 +257,23 @@ function matchesActionId(
261
257
  entryActionId: string,
262
258
  ): boolean {
263
259
  if (subscriptionId.includes("#")) {
264
- // Full ID: exact match
265
260
  return subscriptionId === entryActionId;
266
261
  }
267
- // Action name only: suffix match (matches "anything#actionName")
268
262
  return entryActionId.endsWith(`#${subscriptionId}`);
269
263
  }
270
264
 
271
- // ============================================================================
272
- // Implementation
273
- // ============================================================================
265
+ // Batch rapid notifications into one microtask to prevent render storms
266
+ function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
267
+ let timeout: ReturnType<typeof setTimeout> | null = null;
268
+ return () => {
269
+ if (timeout !== null) clearTimeout(timeout);
270
+ timeout = setTimeout(() => {
271
+ timeout = null;
272
+ listeners.forEach((listener) => listener());
273
+ }, 0);
274
+ };
275
+ }
274
276
 
275
- /**
276
- * Configuration for creating an event controller
277
- */
278
277
  export interface EventControllerConfig {
279
278
  initialLocation?: NavigationLocation;
280
279
  }
@@ -292,62 +291,34 @@ export interface EventControllerConfig {
292
291
  export function createEventController(
293
292
  config?: EventControllerConfig,
294
293
  ): EventController {
295
- // ========================================================================
296
- // Source of Truth
297
- // ========================================================================
298
-
299
- // Current navigation in progress (null = idle)
300
294
  let currentNavigation: NavigationEntry | null = null;
301
295
 
302
- // All in-flight actions (keyed by unique instance ID)
303
296
  const inflightActions = new Map<string, ActionEntry>();
304
297
 
305
- // Committed location (updated when navigation completes)
306
298
  let location: NavigationLocation =
307
299
  config?.initialLocation ??
308
300
  (typeof window !== "undefined"
309
301
  ? new URL(window.location.href)
310
302
  : new URL("/", "http://localhost"));
311
303
 
312
- // Track if any concurrent actions occurred (for consolidation)
313
304
  let hadAnyConcurrentActions = false;
314
305
 
315
- // Track segments revalidated by concurrent actions
316
306
  const concurrentRevalidatedSegments = new Set<string>();
317
307
 
318
- // Active streaming count (independent of navigation/action lifecycle)
319
308
  let activeStreamCount = 0;
320
309
 
321
- // Handle data from RSC payload
322
310
  let handleData: HandleData = {};
323
311
  let handleSegmentOrder: string[] = [];
324
312
  let routeSegmentIds: string[] = [];
325
313
 
326
- // Merged route params from current match
327
314
  let routeParams: Record<string, string> = {};
328
315
 
329
- // ========================================================================
330
- // Listeners
331
- // ========================================================================
332
-
333
316
  const stateListeners = new Set<StateListener>();
334
317
  const actionListeners = new Map<string, Set<ActionStateListener>>();
335
318
  const handleListeners = new Set<HandleListener>();
336
319
 
337
- // Debounce state notifications to batch rapid updates
338
- let notifyTimeout: ReturnType<typeof setTimeout> | null = null;
339
-
340
- function notify() {
341
- if (notifyTimeout !== null) {
342
- clearTimeout(notifyTimeout);
343
- }
344
- notifyTimeout = setTimeout(() => {
345
- notifyTimeout = null;
346
- stateListeners.forEach((listener) => listener());
347
- }, 0);
348
- }
320
+ const notify = makeDebouncedNotifier(stateListeners);
349
321
 
350
- // Debounce per-action notifications
351
322
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
352
323
 
353
324
  function notifyAction(actionId: string) {
@@ -359,8 +330,6 @@ export function createEventController(
359
330
  actionId,
360
331
  setTimeout(() => {
361
332
  actionNotifyTimeouts.delete(actionId);
362
- // Notify all listeners whose subscription ID matches this action
363
- // This includes exact matches and suffix matches (e.g., "addToCart" matches "hash#addToCart")
364
333
  for (const [subscriptionId, listeners] of actionListeners) {
365
334
  if (matchesActionId(subscriptionId, actionId)) {
366
335
  const state = getActionState(subscriptionId);
@@ -371,25 +340,9 @@ export function createEventController(
371
340
  );
372
341
  }
373
342
 
374
- // Debounce handle notifications
375
- let handleNotifyTimeout: ReturnType<typeof setTimeout> | null = null;
376
-
377
- function notifyHandles() {
378
- if (handleNotifyTimeout !== null) {
379
- clearTimeout(handleNotifyTimeout);
380
- }
381
- handleNotifyTimeout = setTimeout(() => {
382
- handleNotifyTimeout = null;
383
- handleListeners.forEach((listener) => listener());
384
- }, 0);
385
- }
386
-
387
- // ========================================================================
388
- // Derived State
389
- // ========================================================================
343
+ const notifyHandles = makeDebouncedNotifier(handleListeners);
390
344
 
391
345
  function getState(): DerivedNavigationState {
392
- // Build inflight actions list (for compatibility with existing API)
393
346
  const inflightActionsList: InflightAction[] = [...inflightActions.values()]
394
347
  .filter((a) => a.phase !== "settling")
395
348
  .map((a) => ({
@@ -399,15 +352,12 @@ export function createEventController(
399
352
  startedAt: a.startedAt,
400
353
  }));
401
354
 
402
- // State: loading if navigation OR actions are in progress
403
- // Background revalidations (skipLoadingState) don't affect visible state
404
355
  const hasActiveActions = inflightActionsList.length > 0;
405
356
  const isVisibleNavigation =
406
357
  currentNavigation !== null &&
407
358
  !currentNavigation.options?.skipLoadingState;
408
359
  const state = isVisibleNavigation || hasActiveActions ? "loading" : "idle";
409
360
 
410
- // Streaming: true if any active streams (navigation or action) or loading
411
361
  const isStreaming = activeStreamCount > 0 || state === "loading";
412
362
 
413
363
  return {
@@ -429,28 +379,20 @@ export function createEventController(
429
379
  }
430
380
 
431
381
  function getActionState(actionId: string): TrackedActionState {
432
- // Find the most recent action with this ID that's not settling
433
- // Uses suffix matching when actionId is just a name (no #)
434
- const activeEntry = [...inflightActions.values()]
435
- .filter(
436
- (a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling",
437
- )
438
- .sort((a, b) => b.startedAt - a.startedAt)[0];
439
-
440
- // Also check for settling entries to get result/error
441
- const settlingEntry = [...inflightActions.values()]
442
- .filter(
443
- (a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
444
- )
445
- .sort((a, b) => b.startedAt - a.startedAt)[0];
446
-
447
- const entry = activeEntry || settlingEntry;
382
+ const entry = [...inflightActions.values()]
383
+ .filter((a) => matchesActionId(actionId, a.actionId))
384
+ .reduce<ActionEntry | undefined>((best, a) => {
385
+ if (!best) return a;
386
+ const aActive = a.phase !== "settling";
387
+ const bActive = best.phase !== "settling";
388
+ if (aActive !== bActive) return aActive ? a : best;
389
+ return a.startedAt > best.startedAt ? a : best;
390
+ }, undefined);
448
391
 
449
392
  if (!entry) {
450
393
  return { ...DEFAULT_ACTION_STATE };
451
394
  }
452
395
 
453
- // Derive state from phase
454
396
  let state: ActionLifecycleState;
455
397
  switch (entry.phase) {
456
398
  case "fetching":
@@ -632,6 +574,19 @@ export function createEventController(
632
574
  doSettle();
633
575
  }
634
576
 
577
+ // streamingEnded is forced here for the "streaming never started" case so
578
+ // tryFinalize can run; otherwise the streaming token's end() finalizes.
579
+ function settleWith(result: NonNullable<typeof pendingResult>) {
580
+ if (!inflightActions.has(id) || settled) return;
581
+ actionCompleted = true;
582
+ entry.completed = true;
583
+ pendingResult = result;
584
+ if (entry.phase === "fetching" || streamingEnded) {
585
+ streamingEnded = true;
586
+ tryFinalize();
587
+ }
588
+ }
589
+
635
590
  return {
636
591
  id,
637
592
  abort,
@@ -668,35 +623,11 @@ export function createEventController(
668
623
  },
669
624
 
670
625
  complete(result?: unknown) {
671
- if (!inflightActions.has(id) || settled) return;
672
-
673
- actionCompleted = true;
674
- entry.completed = true;
675
- pendingResult = { type: "success", value: result };
676
-
677
- // If streaming never started or already ended, finalize immediately
678
- // Otherwise wait for streaming to end
679
- if (entry.phase === "fetching" || streamingEnded) {
680
- streamingEnded = true; // Mark as ended if never started
681
- tryFinalize();
682
- }
683
- // If streaming is in progress, tryFinalize() will be called when streaming ends
626
+ settleWith({ type: "success", value: result });
684
627
  },
685
628
 
686
629
  fail(error: unknown) {
687
- if (!inflightActions.has(id) || settled) return;
688
-
689
- actionCompleted = true;
690
- entry.completed = true;
691
- pendingResult = { type: "error", value: error };
692
-
693
- // If streaming never started or already ended, finalize immediately
694
- // Otherwise wait for streaming to end
695
- if (entry.phase === "fetching" || streamingEnded) {
696
- streamingEnded = true; // Mark as ended if never started
697
- tryFinalize();
698
- }
699
- // If streaming is in progress, tryFinalize() will be called when streaming ends
630
+ settleWith({ type: "error", value: error });
700
631
  },
701
632
 
702
633
  getRevalidatedSegments(): Set<string> {
@@ -905,40 +836,3 @@ export function createEventController(
905
836
  hadAnyConcurrentActions: () => hadAnyConcurrentActions,
906
837
  };
907
838
  }
908
-
909
- // ============================================================================
910
- // Singleton
911
- // ============================================================================
912
-
913
- let controllerInstance: EventController | null = null;
914
-
915
- /**
916
- * Initialize the global event controller
917
- */
918
- export function initEventController(
919
- config?: EventControllerConfig,
920
- ): EventController {
921
- if (!controllerInstance) {
922
- controllerInstance = createEventController(config);
923
- }
924
- return controllerInstance;
925
- }
926
-
927
- /**
928
- * Get the global event controller
929
- */
930
- export function getEventController(): EventController {
931
- if (!controllerInstance) {
932
- throw new Error(
933
- "Event controller not initialized. Call initEventController first.",
934
- );
935
- }
936
- return controllerInstance;
937
- }
938
-
939
- /**
940
- * Reset the controller instance (for testing)
941
- */
942
- export function resetEventController(): void {
943
- controllerInstance = null;
944
- }