The solution works by replacing the existing Router
with a very lightly modified version of the out-of-the-box Router
. The new BlazrRouter
replaces the DI injected NavigationManager
service with an interface defined service. IBlazrNavigationManager
defines the same NavigationManager properties and methods as the original. The Router code doesn't care.
We can then define an intermediate NavigationManager between the standard Blazor NavigationManager
or RemoteNavigationManager
and the Router amd control over whether we pass navigation requests to the router or discard them.
The one drawback is that the code has no access to HotReloadManager
(thanks Microsoft!) so hot reloading is disabled. Anyone know how to do it?
First the interface. This defines the functionality used by the router and decouples the BlazrRouter
from the NavigationManager implementation.
public interface IBlazrNavigationManager
{
public string Uri { get; }
public string BaseUri { get; }
public event EventHandler<LocationChangedEventArgs> LocationChanged;
public string ToBaseRelativePath(string uri);
public void NavigateTo(string uri, bool forceLoad);
public void NavigateTo(string uri, bool forceLoad = false, bool replace = false);
public void NavigateTo(string uri, NavigationOptions options);
}
The three changes to the standard router are:
- Changing the type on the injected
NavigationManager
.
[Inject] public virtual IBlazrNavigationManager NavigationManager { get; set; }
- Disabling the
HotReloadManager
inAttach
andDispose
.HotReloadManager
isinternal
to the original code base.
public void Attach(RenderHandle renderHandle)
{
_logger = LoggerFactory.CreateLogger<BlazrRouter>();
_renderHandle = renderHandle;
_baseUri = NavigationManager.BaseUri;
_locationAbsolute = NavigationManager.Uri;
NavigationManager.LocationChanged += OnLocationChanged;
// <<<====================== THIS IS A CHANGE ========================>>>
//if (HotReloadManager.Default.MetadataUpdateSupported)
//{
// HotReloadManager.Default.OnDeltaApplied += ClearRouteCaches;
//}
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
// <<<====================== THIS IS A CHANGE ========================>>>
//if (HotReloadManager.Default.MetadataUpdateSupported)
//{
// HotReloadManager.Default.OnDeltaApplied -= ClearRouteCaches;
//}
}
This is a simple transparent pass-through Navigation Manager. There's no logic applied. Using this with BlazrRouter
provides the same functionality as the standard router. Note it inherits from NavigationManager
and implements IBlazrNavigationManager
.
public class CoreNavigationManager : NavigationManager, IBlazrNavigationManager, IDisposable
{
private NavigationManager _baseNavigationManager;
public CoreNavigationManager(NavigationManager? baseNavigationManager)
{
_baseNavigationManager = baseNavigationManager!;
base.Initialize(_baseNavigationManager!.BaseUri, _baseNavigationManager.Uri);
_baseNavigationManager.LocationChanged += OnBaseLocationChanged;
}
protected override void NavigateToCore(string uri, bool forceLoad)
=> _baseNavigationManager.NavigateTo(uri, forceLoad);
protected override void EnsureInitialized()
=> base.Initialize(_baseNavigationManager.BaseUri, _baseNavigationManager.Uri);
private void OnBaseLocationChanged(object? sender, LocationChangedEventArgs e)
{
this.Uri = e.Location;
// Trigger the Location Changed event for all listeners
this.NotifyLocationChanged(e.IsNavigationIntercepted);
}
public void Dispose()
=> _baseNavigationManager.LocationChanged -= OnBaseLocationChanged;
}
This is the real implementation.
_baseNavigationManager
is where we keep the Blazor standardNavigationManager
_isBlindNavigation
is a bool used by the Url reset process - more later.IsLocked
is our locking control property. You can only set it throughSetLockState
.- There are three events whose purpose is self evident from their names, and three notify methods for them.
- The constructor gets the Blazor Navigation Manager though DI, makes sure this instance is initialized, and registers an event handler with the Blazor Navigation Manager
LocationChanged
event. Dispose
clears up the event registration.
public class BlazrNavigationManager : NavigationManager, IBlazrNavigationManager, IDisposable
{
private NavigationManager _baseNavigationManager;
private bool _isBlindNavigation = false;
public bool IsLocked { get; protected set; } = false;
public event EventHandler<BlazrNavigationEventArgs>? NavigationEventBlocked;
public event EventHandler<LockStateEventArgs>? LockStateChanged;
public event EventHandler? BrowserExitAttempted;
public BlazrNavigationManager(NavigationManager? navigationManager)
{
_baseNavigationManager = navigationManager!;
base.Initialize(navigationManager!.BaseUri, navigationManager.Uri);
_baseNavigationManager.LocationChanged += OnBaseLocationChanged;
}
// code to follow ...........
protected void NotifyNavigationEventBlocked(object? sender, BlazrNavigationEventArgs e)
=> this.NavigationEventBlocked?.Invoke(sender, e);
protected void NotifyLockStateChanged(object? sender, LockStateEventArgs e)
=> this.LockStateChanged?.Invoke(sender, e);
public void NotifyBrowserExitAttempt(object? sender)
=> this.BrowserExitAttempted?.Invoke(sender, EventArgs.Empty);
public void Dispose()
=> _baseNavigationManager.LocationChanged -= OnBaseLocationChanged;
We need to override two base methods to sort Navigation and initialization.
protected override void NavigateToCore(string uri, bool forceLoad)
=> _baseNavigationManager.NavigateTo(uri, forceLoad);
protected override void EnsureInitialized()
=> base.Initialize(_baseNavigationManager.BaseUri, _baseNavigationManager.Uri);
Locking and Unlocking is controlled by SetLockState
. It sets IsLocked
and raises the LockStateChanged
event.
public void SetLockState(bool state)
{
if (state != this.IsLocked)
{
this.IsLocked = state;
this.NotifyLockStateChanged(this, new LockStateEventArgs(this.IsLocked));
}
}
The event handler for LocationChanged
does all the work. The process is complicated by the fact that the browser displayed Url is the one we are navigating to. If we cancel navigation, we must reset it back to the previous value.
We accomplish this by doing what I call a blind navigation.
The locked process is to set _isBlindNavigation
to true and call NavigateTo
on the base Navigator in the LockedNavigation
method, creating a circular reference: _isBlindNavigation
is the exit mechanism . When OnBaseLocationChanged
gets called the BlindNavigation
method resets _isBlindNavigation
and the event handler exits. LockedNavigation
raises the NavigationEventBlocked
event.
If the lock is not set, we set the global Uri
to the new Uri and trigger a LocationChanged
event that triggers routing.
private void OnBaseLocationChanged(object? sender, LocationChangedEventArgs e)
{
// Check if we are blind navigating - i.e. just resetting the display Uri. - Do nothing
if (this.BlindNavigation())
return;
// Check if Navigation is locked
if (this.LockedNavigation(e.Location))
return;
// Normal Navigation path
// NOTE: We set the Uri before calling notify location changed, as it will use this uri property in its args.
this.Uri = e.Location;
// Trigger the Location Changed event for all listeners including the Router
this.NotifyLocationChanged(e.IsNavigationIntercepted);
// Belt and braces to ensure false
_isBlindNavigation = false;
}
private bool BlindNavigation()
{
if (_isBlindNavigation)
{
_isBlindNavigation = false;
return true;
}
return false;
}
private bool LockedNavigation(string uri)
{
// Sets the displayed uri back to the orginal if we're locked.
if (this.IsLocked)
{
// prevents a NavigateTo loop
_isBlindNavigation = true;
_baseNavigationManager.NavigateTo(this.Uri, false);
this.NotifyNavigationEventBlocked(this, new BlazrNavigationEventArgs(uri));
}
return this.IsLocked;
}
PageLocker
is a UI component that interacts with the browser.
It injects JsRuntime
and IBlazrNavigationManager
.
- It Registers a handler on the NavigationManager's
LockStateChanged
event. This drivesSetPageExitCheck
which activates or de-activates the browserbeforeunload
event listener - JS code below. - When
beforeunload
is activeAgentExitAttempt
is wired up so any attempt to exit drives the event into the NavigationManager and raises theBrowserExitAttempted
event.
public class PageLocker : ComponentBase, IDisposable
{
[Inject] private IJSRuntime? _js { get; set; }
[Inject] private IBlazrNavigationManager? _navManager { get; set; }
private BlazrNavigationManager? NavManager => (_navManager is BlazrNavigationManager) ? _navManager as BlazrNavigationManager : null;
protected override void OnInitialized()
{
if (this.NavManager is not null)
NavManager.LockStateChanged += OnLockStateChanged;
}
private void OnLockStateChanged(object? sender, LockStateEventArgs e)
=> this.SetPageExitCheck(e.State);
private void SetPageExitCheck(bool state)
{
var objref = DotNetObjectReference.Create(this);
_js!.InvokeAsync<bool>("blazr_setEditorExitCheck", objref , state);
}
[JSInvokable]
public Task AgentExitAttempt()
{
NavManager?.NotifyBrowserExitAttempt(this);
return Task.CompletedTask;
}
public void Dispose()
{
if (this.NavManager is not null)
NavManager.LockStateChanged -= OnLockStateChanged;
}
}
The JS code loaded into the SPA is as follows. It registers or de-registers a method with the browser's beforeunload
event. It also provides a callback mechanism to notify the C# code that an attempt to exit has been made.
window.blazr_setEditorExitCheck = function (dotNetHelper, show) {
if (show) {
window.addEventListener("beforeunload", blazr_showExitDialog);
blazrDotNetExitHelper = dotNetHelper;
}
else {
window.removeEventListener("beforeunload", blazr_showExitDialog);
blazrDotNetExitHelper = null;
}
}
var blazrDotNetExitHelper;
window.blazr_showExitDialog = function (event) {
event.preventDefault();
event.returnValue = "There are unsaved changes on this page. Do you want to leave?";
blazrDotNetExitHelper.invokeMethodAsync("AgentExitAttempt");
}
This provides some convenient methods to add the Routing services to an application.
The implementation solutiuon is based on one of my templates.
This contains the UI code.
EditForm
is a demostrator with no data, just a button to emulate dirty state. Note the PageLocker
component.
@page "/EditForm"
@namespace Blazr.NavigationManager.UI
<PageLocker />
<div class="p-2 border @this.DivCss border-2 bg-light">
<h3>EditForm</h3>
<div class="p-2">Click on the button to emulate changing a value in the form.</div>
<div class="p-2 m-2 text-end">
<button class="btn @this.BtnCss me-1" @onclick="this.SwitchDirtyState">@this.BtnText</button>
</div>
</div>
<div class="p-2 m-2">
@((MarkupString)this.message)
</div>
There's a lot of code to sort the UI, but the basics are:
- Get the
IBlazrNavigationManager
and convert it toBlazrNavigationManager
. - Add event handlers so we can display messages to demo the event functionality.
- Change Edit state by calling
BlazrNavManager.SetLockState
.
public partial class EditForm : ComponentBase, IDisposable
{
private bool _dirty;
private string message = string.Empty;
[Inject] private IBlazrNavigationManager? _navigationManager { get; set; }
private IBlazrNavigationManager NavigationManager => _navigationManager!;
private BlazrNavigationManager? BlazrNavManager => _navigationManager BlazrNavigationManager ? NavigationManager as BlazrNavigationManager : null;
private string BtnCss => _dirty ? "btn-danger" : "btn-success";
private string DivCss => _dirty ? "border-danger" : "border-success";
private string BtnText => _dirty ? "Form Edited" : "Form Clean";
protected override void OnInitialized()
{
if (this.BlazrNavManager is not null)
{
this.BlazrNavManager.BrowserExitAttempted += BrowserExitAttempted;
this.BlazrNavManager.NavigationEventBlocked += NavigationAttempted;
}
}
private void BrowserExitAttempted(object? sender, EventArgs e)
{
this.message += "You can't terminate me that easily!<br />";
this.InvokeAsync(this.StateHasChanged);
}
private void NavigationAttempted(object? sender, BlazrNavigationEventArgs e)
{
this.message += $"Naughty! You can't go to {e.Uri} just now.<br />";
this.InvokeAsync(this.StateHasChanged);
}
private void SwitchDirtyState()
{
_dirty = !_dirty;
this.message = string.Empty;
if (BlazrNavManager is not null)
BlazrNavManager.SetLockState(_dirty);
}
public void Dispose()
{
if (this.BlazrNavManager is not null)
{
this.BlazrNavManager.BrowserExitAttempted -= BrowserExitAttempted;
this.BlazrNavManager.NavigationEventBlocked -= NavigationAttempted;
}
}
}
This is the Blazor Server Implementation.
Register the services in Program
.
// Add services to the container.
//...
services.AddBlazrNavigationManager();
//...
Add the JS to _Layouts.cshtml
<script src="_framework/blazor.server.js"></script>
<script src="_content/Blazr.Routing/site.js"></script>
This is the Blazor WASM Implementation.
Register the services in Program
.
builder.RootComponents.Add<HeadOutlet>("head::after");
var services = builder.Services;
{
//...
services.AddBlazrNavigationManager();
}
//...
Add the JS to index.html
<script src="_framework/blazor.webassembly.js"></script>
<script src="_content/Blazr.Routing/site.js"></script>
That's it, it "should" work. Here's a screen shot: