Where are these extra url parameters coming from in my ActionLink?

Posted on March 3rd, 2011

So you've created your web app in MVC and <3 the hell out of it. You have your menu system and your tools working like a champ. Then, out of the blue, users start complaining about one of your tools. Not a big complaint, but enough of a UX annoyance that they raise a stink. They say that when they are looking at reports and switch from one report to another the date range used for the report is passed along and not reset to the default date range for the new report they are navigating to. What could this be?

Lets investigate. To start with, we will set up some example code to simulate what is occurring. We will create an MVC3 project and use the Razor engine, although this issue that we are investigating occurs in MVC2 also (maybe in MVC1 as well but I have not verified) and is not related to the view engine (can occur with the ASPX view engine as well). Our project will have a HomeController for our home page and a ReportsController for two sample reports called Sales and Traffic. We will use the _Layout.cshtml for our site shell and main navigation. We will also create a model called BaseReport and use strongly typed views for our report pages that implement that model.

alt text

Rather than make you wait, I am going to out the culprit right now. Our main navigation is using the Html.ActionLink helper method, and the logic it uses to auto-generate the html is causing our issue. We will see how this occurs as we put all of the pieces into play. First up is our _Layout.cshtml content:




    @ViewBag.Title
    
    



    
  • @Html.ActionLink("Go Home", "Index", "Home")
  • @Html.ActionLink("Sales", "Sales", "Reports")
  • @Html.ActionLink("Traffic", "Traffic", "Reports")
@RenderBody()

We have our calls to Html.ActionLink to handle our main navigation to our reports as well as back to our home page. Our HomeController just contains one action method for handling our home page content.

using System.Web.Mvc;

namespace Website.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }
    }
}

The default _ViewStart.cshtml file that was created by Visual Studio in our project is already wired up to handle our default layout for all views in our main Views directory:

@{
    Layout = "~/Views/Shared/_Layout.cshtml";
}

The Index.cshtml view file in the Views/Home directory sets the page title and displays some heading text:

@{
    ViewBag.Title = "Home";
}

Our reporting application

The ReportsController will have two action methods, one for Sales and one for Traffic. Both will have optional DateTime parameters for startDate and endDate. A user can navigate to one of these reports without a date range and get a default date range (the past month) or they can navigate to the report and request a specific date range in the GET parameters. The controller code contains our two action methods as well as some helper methods for setting the default DateTime values when none are set:

using System;
using System.Web.Mvc;
using Website.Models;

namespace Website.Controllers
{
    public class ReportsController : Controller
    {
        public ActionResult Sales(DateTime? startDate, DateTime? endDate)
        {
            var model = this.initializeModel(startDate, endDate);
            return View(model);
        }

        public ActionResult Traffic(DateTime? startDate, DateTime? endDate)
        {
            var model = this.initializeModel(startDate, endDate);
            return View(model);
        }

        private BaseReport initializeModel(DateTime? startDate, DateTime? endDate)
        {
            var validStartDate = this.initializeDate(startDate, DateTime.Now.AddMonths(-1));
            var validEndDate = this.initializeDate(endDate, DateTime.Now.AddDays(-1));
            return new BaseReport(validStartDate, validEndDate);
        }

        private DateTime initializeDate(DateTime? date, DateTime defaultDate)
        {
            if(date == null || date == DateTime.MinValue)
                return defaultDate;
            return date.GetValueOrDefault();
        }
    }
}

Our view content for Sales.cshtml is as follows:

@model Website.Models.BaseReport
@{
    ViewBag.Title = "Sales";
}

Sales Report

From @Model.StartDate.ToString("MMM d, yyyy") to @Model.EndDate.ToString("MMM d, yyyy")

@Html.ActionLink("Back Another Month", "Sales", new { startDate = Model.StartDate.AddMonths(-1).ToString("yyyy-MM-dd"), endDate = Model.EndDate.AddMonths(-1).ToString("yyyy-MM-dd") })

We are defining it as a strongly typed view with the line:

@model Website.Models.BaseReport

Then we set the title value and add our html for displaying the report name and current date range. Below the date range we would have our report data (we will leave that out of our example as it doesn't pertain to the issue we are investigating), and below that we will have an Html.ActionLink for changing the date range. This is just a fixed date range link for the sake of testing our example, however you could imagine this could be a set of date picker controls or maybe some other pre-defined date range links. It really doesn't matter, as long as the end result of changing the date leads to a GET request for the current report that includes the start and end dates in the url structure.

Our Traffic.cshtml content is the same as the Sales.cshtml, only the title and heading text as well as the action value in the Html.ActionLink are set to Traffic:

@model Website.Models.BaseReport
@{
    ViewBag.Title = "Traffic";
}

Website Traffic Report

From @Model.StartDate.ToString("MMM d, yyyy") to @Model.EndDate.ToString("MMM d, yyyy")

@Html.ActionLink("Back Another Month", "Traffic", new { startDate = Model.StartDate.AddMonths(-1).ToString("yyyy-MM-dd"), endDate = Model.EndDate.AddMonths(-1).ToString("yyyy-MM-dd") })

Finally, we want to add two routes to our route table in the Global.asax file so that our url structure will look pretty and not contain any old school url parameter string:

using System.Web.Mvc;
using System.Web.Routing;

namespace Website
{
    public class MvcApplication : System.Web.HttpApplication
    {
        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new HandleErrorAttribute());
        }

        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            routes.MapRoute(
                "SalesReport",
                "Reports/Sales/{startDate}/{endDate}",
                new { controller = "Reports", action = "Sales", 
                      startDate = UrlParameter.Optional, endDate = UrlParameter.Optional }
            );

            routes.MapRoute(
                "TrafficReport",
                "Reports/Traffic/{startDate}/{endDate}",
                new { controller = "Reports", action = "Traffic", 
                      startDate = UrlParameter.Optional, endDate = UrlParameter.Optional }
            );

            routes.MapRoute(
                "Default",
                "{controller}/{action}/{id}",
                new { controller = "Home", action = "Index", id = UrlParameter.Optional }
            );
        }

        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();

            RegisterGlobalFilters(GlobalFilters.Filters);
            RegisterRoutes(RouteTable.Routes);
        }
    }
}

If we left out these route additions our links would still work since Html.ActionLink will render the url as:

http://localhost:/Reports/Sales?startDate=2011-01-03&endDate=2011-02-02

MVC will handle mapping those GET parameters into the action method parameters. However, since MVC has the power of routing we would like to leverage that and keep our urls looking slick. So we add the routes to do so, which makes us happy...but now we have our problem!

Apparently the Html.ActionLink code is interested in your view data. Since our BaseReport model that we are passing to our view contains property fields for StartDate and EndDate and our routes define parameters with the same spelling (regardless of case), the Html.ActionLink will extract the data from our view model and use the values in the link html that it renders. What? Why? Well, this is somewhat of a double-edged sword. There may be cases where you want this action to occur...I guess. Actually, I can't think of one off the top of my head right now. Lets use our application and see where this issue becomes a problem.

If we F5 our application we get our home page and our Sales and Traffic links are nice and clean (no date range appended).

alt text

If we navigate to the Sales page and then click on our Back Another Month link we will end up at the url that contains our start and end date values. Our date range display will reflect the new range.

alt text

Now, if we hover over our main navigation link for Traffic we will notice our current date range is in the url structure. Oh noes!

alt text

We have replicated the issue our users have been complaining about. If they go from one report page to a different report the date range they were using for their previous report carries over to the new report. I guess we could call this a "feature enhancement"...but our users don't think so. So how do we solve this? We can add two new routes to our route table that define a route to our reports without any start or end date in the url structure (the url parameter in the MapRoute constructor). The Html.ActionLink code is digging into the route table and looking for matches when it attempts to render the link html. As we know (or if you don't know yet then now you do), routes in the route table are parsed for matches in the order that they are added to the table. So we need to add our desired default routes for our reports ahead of our routes with the date range values. That way we will ensure that the Html.ActionLink method will find a match on those and use them, thus avoiding the auto-set of those parameters in the url. The routes to add are as follows:

routes.MapRoute(
    "SalesReportDefault",
    "Reports/Sales",
    new { controller = "Reports", action = "Sales", 
          startDate = UrlParameter.Optional, endDate = UrlParameter.Optional }
);

routes.MapRoute(
    "TrafficReportDefault",
    "Reports/Traffic",
    new { controller = "Reports", action = "Traffic", 
          startDate = UrlParameter.Optional, endDate = UrlParameter.Optional }
);

Note that we still need to send in our startDate and endDate parameters with our object default values in our MapRoute constructor. If we left those off then our Html.ActionLink call would render out the url with our old school GET parameters attached to the link with a question mark.

If we do another F5 (be aware that you may need to kill off any previous running instances of the VS debug web server before doing so to ensure your route changes have been applied) and we test the same navigation process we can see that our urls are so fresh and so clean:

alt text

I am sure that there is a viable reason for having the Html.ActionLink method do this sort of logic (if you know, add a comment). If I come across the reason I will add a comment as well. But, like we learned in the 80's, "Knowing is half the battle". If you come across this issue you are fully prepared to resolve it. Thank you interwebs. Now get back out there and continue the MVC offensive soldier!

Discussion

leonxki
leonxki
26 Mar, 2011 08:35 PM

Mmmm, thanks for sharing. Will watch out for this one and act accordingly. Thanks.

Leon

28 Mar, 2011 01:58 AM

Leon
Welcome. Glad to share!

Bioniaidort
Bioniaidort
10 Jun, 2011 07:07 AM

:)

Bazi
Bazi
23 Jun, 2011 04:55 PM

Thanks!

This was the first result Google returned to me and I am sure it is not a coincidence... Very good explanation.

I guess it is hard to see that problem if you don't face it yourself. I have a page with paging which contains links to other pages with paging as well. Both pages have routing maps with parameter 'page', so think of it what bad consequences it lead... While viewing a page with 'page' set to 22, clicking links to others pages would return empty lists, as 'page' would be set to 22, if there are not that much entries. Maybe it is useful if there is one-to-one list where 'page' should remain. Don't know...

Putting two new 'default' routes solved the problem. Great!

23 Jun, 2011 07:09 PM

Bazi
I'm glad that you were able to come across my post and get the answers you were looking for! When I first encountered this issue I wasn't able to find any info on it and had to dig in and figure out what was going on. Once I started my blog this was one of the posts high on my priority list to write. I wanted to get this info out there in hopes that someone else could do a quick search and get an answer on it and not have to go insane. Glad that it is accomplishing that! :)

Mike Finger
Mike Finger
25 Aug, 2011 02:43 PM

Ugh, sometimes the HtmlHelpers aren't helpful.

You can also use Url.Action with a plain old href tag:

My Link

This will generate a clean url structure.

25 Aug, 2011 02:49 PM

Mike Finger
Good tip! Thanks for sharing.

Rudi Steyn
Rudi Steyn
02 Feb, 2012 05:03 AM

Awesome stuff, I have spend the past 6 hours looking for a simple explanation on how MapRoute works with ActionLinks in MVC3.

Thanks you so much for creating such a detailed post not only is it easy to understand answers all of the basic questions around MapRoutes.

Awesome !!!!!!!!!

02 Feb, 2012 11:11 AM

I also found this behavoir annoying and ended up doing this on links to get around it:

@Html.ActionLink("Home", "index", new { id = (int?)null, start = (int?)null })

I am newish to mvc so to hear it is a routing issue means I need to read up routing a bit more.

Although I'd say it was probably a bug since one would expect routing to be symmetrical and the actionlink to pay attention to the parameters passed in (or not passed in).

No new comments are allowed on this post.