CEC.Routing

Single Page Applications have several issues running as applications in a web browser. One such issue is navigation: the user can navigate away from the page in a variety of ways and we have little control over what happens. Data loss often occurs in a less than satisfactory experience for the user.

This library seeks to address the following navigation issues:

1. Intercepting and potentially cancelling intra-application routing when the page is dirty.

2. Intercepting and warning on navigation away from the application - there’s no browser mechanism to prevent this. Entering a new URL in the URL bar, clicking on a favourite, …

# Library and Example Repositories

CEC.Routing is my implementation of the standard Blazor router with functionality needed to control intra-application routing and onbeforeunload behaviour. It’s released and available as a Nuget Package. The source code is available at https://github.com/ShaunCurtis/CEC.Routing.

All the source code is available under the MIT license.

# Intra-Application Routing

Intercept routing on a dirty i.e. unsaved data page is not possible with the out-of-the-box Blazor navigator/router. Not only that, the methods and properties we need to override aren’t exposed, so, it's start from scratch.

A quick digression here to explain the basics of Blazor navigation/routing. DOM navigation events – anchors, etc – are captured by the Blazor JavaScript Interop code. They surface in the C# Blazor world through the NavigationManager. The user clicks on an HTML link or navlink in the browser, the NavigationManager service instance gets populated with the relevant URL data and the NavigationManager.LocationChanged event is fired. That’s it for the NavigationManager. The heavy lifting is done by the Router. It gets initialized through app.razor on a page load, and wires itself into the NavigationManager.LocationChanged event. The developer has no access to its internal workings, so can’t cancel anything.

Fortunately, we can clone the standard router and add the necessary functionality. My new router is called RecordRouter. The key changes to the out-of-the-box router are as follows:

## RouterSessionService

Create a new scoped Service called RouterSessionService for controlling and interacting with the RecordRouter.

public class RouterSessionService

{

/// <summary>

/// Property containing the currently loaded component if set

/// </summary>

public IRecordRoutingComponent ActiveComponent { get; set; }

/// <summary>

/// Boolean to check if the Router Should Navigate

/// </summary>

public bool IsGoodToNavigate => this.ActiveComponent?.IsClean ?? true;

/// <summary>

/// Url of Current Page being navigated from

/// </summary>

public string PageUrl => this.ActiveComponent?.PageUrl ?? string.Empty;

/// <summary>

/// Url of the previous page

/// </summary>

public string LastPageUrl { get; set; }

/// <summary>

/// Url of the navigation cancelled page

/// </summary>

public string NavigationCancelledUrl { get; set; }

/// <summary>

/// Event to notify Navigation Cancellation

/// </summary>

public event EventHandler NavigationCancelled;

/// <summary>

/// Event to notify that Intra Page Navigation has taken place

/// useful when using Querystring controlled pages

/// </summary>

public event EventHandler IntraPageNavigation;

private readonly IJSRuntime _js;

private bool _ExitShowState { get; set; }

public RouterSessionService(IJSRuntime js) => _js = js;

/// <summary>

/// Method to trigger the NavigationCancelled Event

/// </summary>

public void TriggerNavigationCancelledEvent() => this.NavigationCancelled?.Invoke(this, EventArgs.Empty);

/// <summary>

/// Method to trigger the IntraPageNavigation Event

/// </summary>

public void TriggerIntraPageNavigation() => this.IntraPageNavigation?.Invoke(this, EventArgs.Empty);

/// <summary>

/// Method to set or unset the browser onbeforeexit challenge

/// </summary>

/// <param name="action"></param>

/// <returns></returns>

public void SetPageExitCheck(bool show)

{

if (show != _ExitShowState) _js.InvokeAsync<bool>("cec_setEditorExitCheck", show);

_ExitShowState = show;

}

}

##

## RecordRouter

This is a straight clone of the shipped router. The only changes are in the OnLocationChanged event handler. It now looks like this:

private void OnLocationChanged(object sender, LocationChangedEventArgs args)

{

// Get the Page Uri minus any query string

var pageurl = this.NavigationManager.Uri.Contains("?") ? this.NavigationManager.Uri.Substring(0, this.NavigationManager.Uri.IndexOf("?")): this.NavigationManager.Uri ;

_locationAbsolute = args.Location;

// CEC ADDED - SessionState Check for Unsaved Page

if (_renderHandle.IsInitialized && Routes != null && this.RouterSessionService.IsGoodToNavigate)

{

// Clear the Active Component - let the next page load itself into it if required

this.RouterSessionService.ActiveComponent = null;

this.RouterSessionService.NavigationCancelledUrl = null;

Refresh(args.IsNavigationIntercepted);

}

else

{

// CEC ADDED - Trigger a Navigation Cancelled Event on the SessionStateService

if (this.RouterSessionService.PageUrl.Equals(_locationAbsolute, StringComparison.CurrentCultureIgnoreCase))

{

// Cancel routing

this.RouterSessionService.TriggerNavigationCancelledEvent();

}

else

{

// we're cancelling routing, but the Navigation Manager is current set to the aborted page

// so we set the navigation cancelled url so the page can navigate to it if necessary

// and do a dummy trip through the Navigation Manager again to set this back to the original page

this.RouterSessionService.NavigationCancelledUrl = this.NavigationManager.Uri;

this.NavigationManager.NavigateTo(this.RouterSessionService.PageUrl);

}

}

if (RouterSessionService.LastPageUrl != null && RouterSessionService.LastPageUrl.Equals(pageurl, StringComparison.CurrentCultureIgnoreCase)) RouterSessionService.TriggerIntraPageNavigation();

RouterSessionService.LastPageUrl = pageurl;

}

On a NavigationManager.LocationChanged event, the method:

1. Gets the page URL minus any query string, and checks if we are good to route. (I like to use query strings – more flexible than routing in many instances)

1.1. Yes - clear out the relevant fields on the RouterSessionService and route through the Refresh method.

1.2. No - trigger a NavigationCancelled event. We solve the displayed URL issue by making a dummy run through navigation to reset the displayed URL. Anyone know a better way?

2. Check for Intra-Page Navigation and trigger the IntraPageNavigation event if needed. Useful for URLs with query strings.

The routing is controlled by the IsGoodToNavigate property on the RouterSessionService.

public bool IsGoodToNavigate => this.ActiveComponent?.IsClean ?? true;

This is only false – when we cancel routing - if the ActiveComponent exists and is dirty. A lot of coding/refactoring to make a binary check!

## Setting up you site to use the Router

Install the Nuget Package

###### Startup.cs

Add the CECRouting services

using CEC.Routing;

public void ConfigureServices(IServiceCollection services)

{

….

services.AddCECRouting();

….

}

###### _Imports.razor

Add the following namespace references

@using CEC.Routing

@using CEC.Routing.Services

@using CEC.Routing.Router

###### App.razor

Change the name of the Router to RecordRouter

<RecordRouter AppAssembly="@typeof(Program).Assembly">

<Found Context="routeData">

<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />

</Found>

<NotFound>

<LayoutView Layout="@typeof(MainLayout)">

<p>Sorry, there's nothing at this address.</p>

</LayoutView>

</NotFound>

</RecordRouter>

## Implementing the Router

There’s a sample site on the Github repository demonstrating the use of the library on a WeatherForecast editor.

NOTE - Record routing only kicks in if you set up a page component to use it. Normal pages will route as normal: you don’t need to configure them.

You interact with the router through the RouterSessionService. To configure a page to use the extra routing functionality:

1. Inject the service into any edit page.

2. Implement the IRecordRoutingComponent Interface on the page

Next you need to add an event handler for the navigation cancelled event. This should contain code to tell the user that navigation was cancelled and potentially ask them if they really want to leave the page.

protected virtual void OnNavigationCancelled(object sender, EventArgs e)

{

this.NavigationCancelled = true;

this.ShowExitConfirmation = true;

this.AlertMessage.SetAlert("<b>THIS RECORD ISN'T SAVED</b>. Either <i>Save</i> or <i>Exit Without Saving</i>.", Alert.AlertDanger);

InvokeAsync(this.StateHasChanged);

}

This one (from the EditRecordComponentBase boilerplate in the project):

1. Sets a couple of local properties – used in controlling which buttons display when.

2. Sets an alert box to display.

3. Calls StateHasChanged to refresh the UI.

Add the following code to the component OnInitialized or OnInitializedAsync event

this.PageUrl = this.NavManager.Uri;

this.RouterSessionService.ActiveComponent = this;

this.RouterSessionService.NavigationCancelled += this.OnNavigationCancelled;

This:

1. Sets the PageURL property to the current URL (pages names/directories and routing URLs are now very different).

2. Sets the RouterSessionService ActiveComponent reference to the component.

3. Attaches the above event hander to the RouterSessionService NavigationCancelled Event.

The final bit of the jigsaw is connecting the IRecordRoutingComponent.IsClean property. It’s important to get this right. The router uses this property to route/cancel routing.

In my projects it’s wired directly to the IsClean property on the specific data service associated with the record. It get set when the record in the service changes.

In the CEC.Routing sample project it’s set and unset in the CheckForChanges method which is called whenever an edit control is changed.

The following code shows how to override cancel routing – say if the user has clicked on exit anyway.

protected void ConfirmExit()

{

this.IsClean = true;

if (!string.IsNullOrEmpty(this.RouterSessionService.NavigationCancelledUrl)) this.NavManager.NavigateTo(this.RouterSessionService.NavigationCancelledUrl);

else if (!string.IsNullOrEmpty(this.RouterSessionService.LastPageUrl)) this.NavManager.NavigateTo(this.RouterSessionService.LastPageUrl);

else this.NavManager.NavigateTo("/");

}

Note that the RouterSessionService holds the cancelled URL.

# Intercepting/Warning on external Navigation

You can’t lock down the browser window to stop this – I wish we could. The only control browsers offer is the onbeforeunload event. When a function is registered on this event, the browser displays a popup warning dialog, giving the user the option to cancel navigation. The degree of control, what appears in the box, and what you need the attached function to do differs across browsers.

The sledgehammer approach is to add the following to your _Host.html file:

<script>

window.onbeforeunload = function () {

return "Do you really want to leave?";

};

</script>

However, it’s akin to using a wrecking ball to crack a nut. There are many instances where you want to leave the “application” – authentication, print pages to name a couple. Having the exit popup box coming up every time is a pain.

CEC.Routing implements a more nuanced and focused alternative. It still uses the onbeforeunload event, but dynamically registers and unregisters with the event as needed. i.e. only when a form is dirty.

###### CEC.Routing.js

The client side Javascript files looks like this (pretty self explanatory):

window.cec_setEditorExitCheck = function (show) {

if (show) {

window.addEventListener("beforeunload", cec_showExitDialog);

}

else {

window.removeEventListener("beforeunload", cec_showExitDialog);

}

}

window.cec_showExitDialog = function (event) {

event.preventDefault();

event.returnValue = "There are unsaved changes on this page. Do you want to leave?";

}

The JSInterop code is implemented as a method in RouteSessionService.

private bool _ExitShowState { get; set; }

public void SetPageExitCheck(bool show)

{

if (show != _ExitShowState) _js.InvokeAsync<bool>("cec_setEditorExitCheck", show);

_ExitShowState = show;

}

Add the following script reference to the _Host.html next to the blazor.server.js reference.

<script src="_content/CEC.Routing/cec.routing.js"></script>

In the CEC.Routing sample WeatherForcastEditor:

protected void CheckClean(bool setclean = false)

{

if (setclean) this.IsClean = true;

if (this.IsClean)

{

this.Alert.ClearAlert();

this.RouterSessionService.SetPageExitCheck(false);

}

else

{

this.Alert.SetAlert("Forecast Changed", Alert.AlertWarning);

this.RouterSessionService.SetPageExitCheck(true);

}

}

This method is called whenever from the OnFieldChanged event handler and the Save and ConfirmExit methods.