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);
}
}
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;
}
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);
}
}
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);
}
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);
}
}
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
);
});
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);
});
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);
}
}
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);
}
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);
}
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
}
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);
}
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);
}
}
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;
}
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);
}
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());
}
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);
}
}
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);
}
}
}
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);
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;
}
}
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));
}
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);
}
}
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.