DEV Community

Chris Sainty
Chris Sainty

Posted on • Originally published at chrissainty.com on

An In-depth Look at Routing in Blazor

An In-depth Look at Routing in Blazor

In this post, I want to build on my last post, Introduction to Routing in Blazor, and take a deep dive into the nuts and bolts of routing in Blazor.

We're going to look at each part of Blazor's routing model in detail, starting in the JavaScript world where navigation events are picked up. And following the code over the divide to the C# world, to the point of rendering either the correct page or the not found template.

Intercepting navigation events with NavigationManager (JavaScript)

We're going to start off looking at the NavigationManager service. But this isn't the NavigationManager we're used to interacting with in our C# code, this is the JavaScript version.

Intercepting link clicks

Blazor uses something called an EventDelegator to manage the various events produced by DOM elements. This service exposes a function called notifyAfterClick, which the NavigationManager hooks into in order to intercept navigation link click events. When a navigation link click event occurs the following code is run.

if (!hasEnabledNavigationInterception) {
  return;
}

if (event.button !== 0 || eventHasSpecialKey(event)) {
  return;
}

if (event.defaultPrevented) {
  return;
}

const anchorTarget = findClosestAncestor(event.target as Element | null, 'A') as HTMLAnchorElement | null;
const hrefAttributeName = 'href';
if (anchorTarget && anchorTarget.hasAttribute(hrefAttributeName)) {
  const targetAttributeValue = anchorTarget.getAttribute('target');
  const opensInSameFrame = !targetAttributeValue || targetAttributeValue === '_self';
  if (!opensInSameFrame) {
    return;
  }

  const href = anchorTarget.getAttribute(hrefAttributeName)!;
  const absoluteHref = toAbsoluteUri(href);

  if (isWithinBaseUriSpace(absoluteHref)) {
    event.preventDefault();
    performInternalNavigation(absoluteHref, true);
  }
}

Enter fullscreen mode Exit fullscreen mode

We're going to break this code down a piece at a time so we can understand it.

First there are some checks being made before anything more invasive is done.

if (!hasEnabledNavigationInterception) {
  return;
}

if (event.button !== 0 || eventHasSpecialKey(event)) {
  // Don't stop ctrl/meta-click (etc) from opening links in new tabs/windows
  return;
}

if (event.defaultPrevented) {
  return;
}

Enter fullscreen mode Exit fullscreen mode

The first check is to see if navigation interception has been enabled - this gets enabled by Blazor's router component during it's OnAfterRender life-cycle method.

Then there's a check to see if the link was clicked with a modifier key being held - for example, holding ctrl when clicking a link will open the link in a new tab. If a modifier was being held, then the event is allowed to continue normally and open in a new tab. Finally, a check is made to see if the event has had its default behaviour prevented already.

Determining internal navigation

const anchorTarget = findClosestAncestor(event.target as Element | null, 'A') as HTMLAnchorElement | null;
const hrefAttributeName = 'href';

if (anchorTarget && anchorTarget.hasAttribute(hrefAttributeName)) {
  const targetAttributeValue = anchorTarget.getAttribute('target');
  const opensInSameFrame = !targetAttributeValue || targetAttributeValue === '_self';

  if (!opensInSameFrame) {
    return;
  }

  const href = anchorTarget.getAttribute(hrefAttributeName)!;
  const absoluteHref = toAbsoluteUri(href);

  if (isWithinBaseUriSpace(absoluteHref)) {
    event.preventDefault();
    performInternalNavigation(absoluteHref, true);
  }
}

Enter fullscreen mode Exit fullscreen mode

The next section of code checks if the target of the click was an <a> tag, and if it was, that it has an href attribute. If either of these checks fail then the event will be allowed to continue as normal.

Next, a check happens to decide if the link should be opened in the same frame (tab) or not. If not, then again, the event is allowed to continue as normal.

Finally, the value of the href attribute is converted to an absolute URI - if it isn't one already. It's then checked to see if it falls within the scope of the base URI. This is set in the <head> tag of either the index.html (Blazor WebAssembly) or _Hosts.cshtml (Blazor Server) using the <base> element.

If the link falls within the scope of the base element, then it's considered internal navigation. The performInternalNavigation function is called, passing the absolute URI and a boolean value to indicate it was intercepted.

Simulating browser navigation

function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean) {
  resetScrollAfterNextBatch();

  history.pushState(null, /* ignored title */ '', absoluteInternalHref);
  notifyLocationChanged(interceptedLink);
}

Enter fullscreen mode Exit fullscreen mode

The first call, resetScrollAfterNextBatch isn't of much interest to us. It stops unwanted flickering when resetting the scroll position during navigation. But the next part is more interesting.

The new location is pushed into the browsers history. This is what allows the forward and back buttons to function as they would in a traditional web app. By adding the new location to the browsers history it's simulating traditional app navigation. Another important function this action performs is updating the URL in the browsers address bar.

At the end, the notifyLocationChanged function is called.

The gateway to C

async function notifyLocationChanged(interceptedLink: boolean) {
  if (notifyLocationChangedCallback) {
    await notifyLocationChangedCallback(location.href, interceptedLink);
  }
}

Enter fullscreen mode Exit fullscreen mode

The final step before we head into the C# world is the notifyLocationChanged function above. This function checks if there is a notifyLocationChangedCallback and then invokes it, passing the location and whether the link was intercepted.

But where does the notifyLocationChangedCallback come from? Well, that depends.

If we're running on WebAssembly then the callback is registered during the application startup in Boot.WebAssembly.ts.

// Configure navigation via JS Interop
window['Blazor']._internal.navigationManager.listenForNavigationEvents(async (uri: string, intercepted: boolean): Promise<void> => {
    await DotNet.invokeMethodAsync(
      'Microsoft.AspNetCore.Blazor',
      'NotifyLocationChanged',
      uri,
      intercepted
    );
});

Enter fullscreen mode Exit fullscreen mode

If we're running on .NET Core (Blazor Server) then the callback is registered in Boot.Server.ts.

// Configure navigation via SignalR
window['Blazor']._internal.navigationManager.listenForNavigationEvents((uri: string, intercepted: boolean): Promise<void> => {
    return connection.send('OnLocationChanged', uri, intercepted);
});

Enter fullscreen mode Exit fullscreen mode

Navigation Manager (C#)

This leads us into the C# side of things and what responds to the location changed event.

The C# version of NavigationManager listens for the location changed event. But the NavigationManager class is abstract. There are actually two implementations, one for Blazor Server called RemoteNavigationManager. And one for Blazor WebAssembly called WebAssemblyNavigationManager.

The NavigationManager class performs lots of useful operations, but right now, we're only interested in the LocationChanged event. This event gets invoked from different places depending on if we're in a Blazor WebAssembly or Blazor Server application.

Blazor WebAssembly

When the NotifyLocationChanged event is invoked from the JS world it enters the C# world via a class called JSInteropMethods.

public static class JSInteropMethods
{
    /// <summary>
    /// For framework use only.
    /// </summary>
    [JSInvokable(nameof(NotifyLocationChanged))]
    public static void NotifyLocationChanged(string uri, bool isInterceptedLink)
    {
        WebAssemblyNavigationManager.Instance.SetLocation(uri, isInterceptedLink);
    }
}

Enter fullscreen mode Exit fullscreen mode

The NotifyLocationChanged method calls the SetLocation method on the WebAssemblyNavigationManager which looks like this.

public void SetLocation(string uri, bool isInterceptedLink)
{
    Uri = uri;
    NotifyLocationChanged(isInterceptedLink);
}

Enter fullscreen mode Exit fullscreen mode

This method records the new URI and calls the NotifyLocationChanged method on the base NavigationManager - this method invokes an event called LocationChanged.

Blazor Server

In this version the NotifyLocationChanged event enters the C# world via the ComponentHub's OnLocationChanged method.

public async ValueTask OnLocationChanged(string uri, bool intercepted)
{
    var circuitHost = await GetActiveCircuitAsync();
    if (circuitHost == null)
    {
        return;
    }

    _ = circuitHost.OnLocationChangedAsync(uri, intercepted);
}

Enter fullscreen mode Exit fullscreen mode

This method calls the CircuitHost's OnLocationChangedAsync method.

public async Task OnLocationChangedAsync(string uri, bool intercepted)
{
    AssertInitialized();
    AssertNotDisposed();

    try
    {
        await Renderer.Dispatcher.InvokeAsync(() =>
        {
            Log.LocationChange(_logger, uri, CircuitId);
            var navigationManager = (RemoteNavigationManager)Services.GetRequiredService<NavigationManager>();
            navigationManager.NotifyLocationChanged(uri, intercepted);
            Log.LocationChangeSucceeded(_logger, uri, CircuitId);
        });
    }

    // Remaining code omitted for brevity
}

Enter fullscreen mode Exit fullscreen mode

The interesting part for us is in the try block. Essentially, an instance of the RemoteNavigationManager is being retrieved from the DI container and then it's NotifyLocationChanged method is called.

public void NotifyLocationChanged(string uri, bool intercepted)
{
    Log.ReceivedLocationChangedNotification(_logger, uri, intercepted);

    Uri = uri;
    NotifyLocationChanged(intercepted);
}

Enter fullscreen mode Exit fullscreen mode

In much the same way as the WebAssemblyNavigationManager, the new URI is recorded and the NotifyLocationChanged method on the base NavigationManager is called.

But what's listening?

Technically, it could be a few things. The NavigationManager's LocationChanged event is public for anyone to handle after all. But what we're interested in is Blazor's Router component.

The Router Component

When the router is initialised it registers a handler for the LocationChanged event. Which looks like this.

private void OnLocationChanged(object sender, LocationChangedEventArgs args)
{
    _locationAbsolute = args.Location;
    if (_renderHandle.IsInitialized && Routes != null)
    {
        Refresh(args.IsNavigationIntercepted);
    }
}

Enter fullscreen mode Exit fullscreen mode

But in order for the router to function it needs to know what components to load for a particular URI, or route. How does it do this?

Finding Page Components

We looked at the parameters the router accepts in the last post. The router accepts a parameter called AppAssembly, which is required. It also accepts another optional parameter, AdditionalAssemblies. The router passes these assemblies to a class called RouteTableFactory via it's Create method.

public static RouteTable Create(IEnumerable<Assembly> assemblies)
{
    var key = new Key(assemblies.OrderBy(a => a.FullName).ToArray());
    if (Cache.TryGetValue(key, out var resolvedComponents))
    {
        return resolvedComponents;
    }

    var componentTypes = key.Assemblies.SelectMany(a => a.ExportedTypes.Where(t => typeof(IComponent).IsAssignableFrom(t)));
    var routeTable = Create(componentTypes);
    Cache.TryAdd(key, routeTable);
    return routeTable;
}

Enter fullscreen mode Exit fullscreen mode

This method loops over each assembly and pulls out any types which implement IComponent. It then passes them to an internal version of Create for further processing.

internal static RouteTable Create(IEnumerable<Type> componentTypes)
{
    var templatesByHandler = new Dictionary<Type, string[]>();
    foreach (var componentType in componentTypes)
    {
        var routeAttributes = componentType.GetCustomAttributes<RouteAttribute>(inherit: false);

        var templates = routeAttributes.Select(t => t.Template).ToArray();
        templatesByHandler.Add(componentType, templates);
    }
    return Create(templatesByHandler);
}

Enter fullscreen mode Exit fullscreen mode

This next method loops over each component and extracts any RouteAttributes. It then selects the template for each route. A template being what's in the quotes when using a @page directive, @page "/my/route/template" for example.

It then adds the component type and it's templates (there can be more than one @page directive on a component) to a dictionary which is passed to the final overload of Create.

internal static RouteTable Create(Dictionary<Type, string[]> templatesByHandler)
{
    var routes = new List<RouteEntry>();
    foreach (var keyValuePair in templatesByHandler)
    {
        var parsedTemplates = keyValuePair.Value.Select(v => TemplateParser.ParseTemplate(v)).ToArray();
        var allRouteParameterNames = parsedTemplates
            .SelectMany(GetParameterNames)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToArray();

        foreach (var parsedTemplate in parsedTemplates)
        {
            var unusedRouteParameterNames = allRouteParameterNames
                .Except(GetParameterNames(parsedTemplate), StringComparer.OrdinalIgnoreCase)
                .ToArray();
            var entry = new RouteEntry(parsedTemplate, keyValuePair.Key, unusedRouteParameterNames);
            routes.Add(entry);
        }
    }

    return new RouteTable(routes.OrderBy(id => id, RoutePrecedence).ToArray());
}

Enter fullscreen mode Exit fullscreen mode

In this last method, a RouteTable is constructed, which is what will be used later by the Router to know which components to load for a given URI.

Essentially, this method does some house keeping to remove any duplication, checks that templates are valid, etc... Before constructing a RouteEntry, which holds the route template, component type and any unused route parameters. Finally, a new RouteTable is returned.

The router then stores the returned RouteTable so it can use it for route lookups during NavigationChanged events.

Loading Page Components

We now understand how the Router knows where to find the correct components for a given route. So let's get back to the OnLocationChanged method.

private void OnLocationChanged(object sender, LocationChangedEventArgs args)
{
    _locationAbsolute = args.Location;
    if (_renderHandle.IsInitialized && Routes != null)
    {
        Refresh(args.IsNavigationIntercepted);
    }
}

Enter fullscreen mode Exit fullscreen mode

In the code above the router stores the new URI and then performs some checks. One of which is checking that it has a RouteTable. If everything is present and correct the Refresh method is called.

private void Refresh(bool isNavigationIntercepted)
{
    var locationPath = NavigationManager.ToBaseRelativePath(_locationAbsolute);
    locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
    var context = new RouteContext(locationPath);
    Routes.Route(context);

    if (context.Handler != null)
    {
        if (!typeof(IComponent).IsAssignableFrom(context.Handler))
        {
            throw new InvalidOperationException($"The type {context.Handler.FullName} " +
                $"does not implement {typeof(IComponent).FullName}.");
        }

        Log.NavigatingToComponent(_logger, context.Handler, locationPath, _baseUri);

        var routeData = new RouteData(
            context.Handler,
            context.Parameters ?? _emptyParametersDictionary);
        _renderHandle.Render(Found(routeData));
    }
    else
    {
        if (!isNavigationIntercepted)
        {
            Log.DisplayingNotFound(_logger, locationPath, _baseUri);
            _renderHandle.Render(NotFound);
        }
        else
        {
            Log.NavigatingToExternalUri(_logger, _locationAbsolute, locationPath, _baseUri);
            NavigationManager.NavigateTo(_locationAbsolute, forceLoad: true);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

We'll work through the code a piece at a time to understand what's going on.

var locationPath = NavigationManager.ToBaseRelativePath(_locationAbsolute);
locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
var context = new RouteContext(locationPath);
Routes.Route(context);

Enter fullscreen mode Exit fullscreen mode

The code above is converting the current URL to a relative URL, then stripping off any querystrings (?name=chris) or hash strings (#my-div). Then a new RouteContext is created using the remaining path.

A RouteContext takes the string provided and splits it on each / into segments. Finally, the Route method is called on the routing table.

Inside the Route method, each route in the routing table is checked to see if it matches the route in the RouteContext being passed in. This is done by calling the Match method on each RouteEntry.

internal void Match(RouteContext context)
{
    if (Template.Segments.Length != context.Segments.Length)
    {
        return;
    }

    // Parameters will be lazily initialized.
    IDictionary<string, object> parameters = null;
    for (int i = 0; i < Template.Segments.Length; i++)
    {
        var segment = Template.Segments[i];
        var pathSegment = context.Segments[i];
        if (!segment.Match(pathSegment, out var matchedParameterValue))
        {
            return;
        }
        else
        {
            if (segment.IsParameter)
            {
                GetParameters()[segment.Value] = matchedParameterValue;
            }
        }
    }

    context.Parameters = parameters;
    context.Handler = Handler;

    IDictionary<string, object> GetParameters()
    {
        if (parameters == null)
        {
            parameters = new Dictionary<string, object>();
        }

        return parameters;
    }
}

Enter fullscreen mode Exit fullscreen mode

The Match method first checks to see if the number of segments in the routes are the same. If that succeeds, then each route segment is checked individually to ensure a match.

If the segment on the RouteEntry is marked as a parameter, then the value for that segment on the RouteContext is added to a parameters collection. Once each segment has been checked, any parameters are added to the RouteContext along with the Handler for that route, which is the component type.

A match was found - load the page!

if (context.Handler != null)
{
    if (!typeof(IComponent).IsAssignableFrom(context.Handler))
    {
        throw new InvalidOperationException($"The type {context.Handler.FullName} " +
            $"does not implement {typeof(IComponent).FullName}.");
    }

    Log.NavigatingToComponent(_logger, context.Handler, locationPath, _baseUri);

    var routeData = new RouteData(
        context.Handler,
        context.Parameters ?? _emptyParametersDictionary);
    _renderHandle.Render(Found(routeData));
}

Enter fullscreen mode Exit fullscreen mode

This executes if a handler was assigned i.e. a match was found for the route. A final check is made to make sure the handler component is definitely implementing IComponent. If that passes then the a RouteData object is constructed with the handler and any parameters which need to be passed to the handler.

A render is then queued which will use the Found template with the route data. This will then render the correct page component and supply it with any necessary parameters.

No match found - Load not found template

else
{
    if (!isNavigationIntercepted)
    {
        Log.DisplayingNotFound(_logger, locationPath, _baseUri);
        _renderHandle.Render(NotFound);
    }
    else
    {
        Log.NavigatingToExternalUri(_logger, _locationAbsolute, locationPath, _baseUri);
        NavigationManager.NavigateTo(_locationAbsolute, forceLoad: true);
    }
}

Enter fullscreen mode Exit fullscreen mode

First a check is made to see if the navigation was intercepted. If it wasn't intercepted, this can only occur programmatically, so the NotFound template is queued to be rendered.

If it was intercepted then a browser reload is forced to the new location, the main scenario for this would be linking to another page on the same domain which isn't a Blazor component, for example, a standard HTML page or a Razor Page or MVC view.

Summary

That's it! We've reached the end of the journey. We've followed the flow of navigation events from the source, starting in the JavaScript world all the way to the point of rendering either the correct page component or the not found template.

I hope you've found this post interesting, I've certainly learned a lot about how the mechanics of client-side routers work writing this post. Next time, we'll have a go at writing our own router and replacing the default implementation.

Top comments (0)