ASP.NET MVC Url.Action adds current route values to generated url

I have seen this question a couple of times here in SO but none of them with any acceptable answer:

ASP.NET MVC @Url.Action includes current route data
ASP.NET MVC implicitly adds route values

Basically I have Controller with an action method called Group, it has an overload that receives no parameters and displays a list of elements and another one that receives an id and displays details for that group.

If I do something like this:

Url.Action("Group", "Groups");

From the main page of the site (/) it returns an url like this:

"mysite.com/Groups/Group"

which is alright Now, if the current address of the site is /Groups/Group/1 And I call the same method

Url.Action("Group", "Groups");

the returned url is this:

"mysite.com/Groups/Group/1"

It automatically adds the value of the route for the current page when generating the URL. Even if I generate the URL this way:

Url.Action("Group", "Groups", null);

Thus explicitly specifying that I don't want any route values, the generated URL is the same. To get the address I want I have to explicitly set the route value to an empty string, like so:

Url.Action("Group", "Groups", new {id=""});

This will generate the following url:

"mysite.com/Groups/Group"

My question is, why does this happen? If I don't set any route values it shouldn't add them to the generated URL.


Solution 1:

Url.Action will reuse the current request parameters, if you do not explicitly set them. It is by design in outbound url-matching algorithm. When looking for the route data parameters in a process of generating url, parameters are taken from:

1) explicitly provided values

2) values from the current request

3) defaults

In the order I specified above.

Outbound matching algorithm for routes is complicated, so it is good practice to explicitly set all parameters for request, as you did in your example

Solution 2:

My application explicitly sets route values and does not want a magical value from the current request. I want to be in full control.

I have made an extension which coexists with my route library collection. Hence the single RouteValueDictionary param. (See my Route library comment at the bottom)

Here I remove any routevalues from the request prior to generating a url.

(note: for the array.contains ignorecase part, see: How can I make Array.Contains case-insensitive on a string array?)

public static string Action(this UrlHelper helper, 
                            RouteValueDictionary routeValues)
{
    RemoveRoutes(helper.RequestContext.RouteData.Values);

    string url = helper.Action(routeValues["Action"].ToString(), routeValues);
    return url;
}

public static void RemoveRoutes(RouteValueDictionary currentRouteData)
{
    List<string> keyList = new List<string>(currentRouteData.Keys);

    string[] ignore = new[] { "Area", "Controller", "Action" };
    foreach (string key in keyList)
    {
        if (!ignore.Contains(key, StringComparer.CurrentCultureIgnoreCase))
            currentRouteData.Remove(key);
    }
}

I have Form and ActionLink extension methods that uses the RemoveRoutes method. No helper in my mvc library uses a method that is not an extension method i have created. Thereby, all routedata is cleaned up before generating urls.

For reference I use AttributeRouting. Here is an example of one route from my route library.

public static RouteValueDictionary DisplayNews(int newsId)
{
    RouteValueDictionary route = new RouteValueDictionary();
    route["Area"] = _area;
    route["Controller"] = _controller;
    route["Action"] = "DisplayNews";
    route["newsId"] = newsId;
    return route;
}

Solution 3:

So when I read objectbox's answer I thought I would have to modify several links in my code. I then tried adding a default route omitting the default parameters which solved the problem:

routes.MapRoute(
    "ArtistArtworkDefPage",
    "Artist/{username}/Artwork",
    new
    {
        controller = "Artist",
        action = "Artwork",
        page = 1
    }
);

routes.MapRoute(
    "ArtistArtwork",
    "Artist/{username}/Artwork/{page}",
    new
    {
        controller = "Artist",
        action = "Artwork",
        page = 1
    },
    new { page = @"\d+" }
);

Solution 4:

Simple example:

public class ProductController : Controller
{
  public ActionResult Edit(int id)
  {
    return View();
  }

  [Route("Product/Detail/{id:int}")]
  public ActionResult Detail(int id)
  {
    return View();
  }
}

Edit view contains only this:

@{ Layout = null;}
@Url.Action("Detail", "Cmr")

So when you run your site e.g. localhost:randomPort/Product/Edit/123 you get next response: /Product/Detail/123

Why? Because Route attribute has required parameter id. Id parameter is read from url, although we wrote only Url.Action(methodName, controller) - without specifying parameter. Also it doesn't make sense to have a method detail without id.

In order for attributes to work next line must be added to RouteConfig.cs:

public static void RegisterRoutes(RouteCollection routes)
{
  ...
  routes.MapMvcAttributeRoutes();
  ...
}