Description
Background and Motivation
The ability to asynchronously delay/cancel navigations in Blazor has been a very popular ask from the community. Such a feature would allow Blazor developers to implement custom navigation prompts, save changes before navigating, or silently cancel navigations. There was a previous attempt to solve this problem in a limited fashion by using window.confirm()
to display a built-in browser prompt that lets the user confirm navigations. This approach works well for simple cases, but community feedback indicated a desire for a more flexible mechanism, namely a "location changing" event enabling asynchronous callbacks to decide whether a navigation should be allowed or cancelled.
Proposed API
We provide new APIs on NavigationManager
to add and remove "location changing" handlers that run when an "internal" navigation is initiated. These handlers are given a LocationChangingContext
that provides information about the ongoing navigation in addition to a PreventNavigation()
method that prevents the navigation from continuing when invoked. There can be multiple handlers registered that get executed in parallel, where any handler may prevent the navigation.
In addition, we also provide a <NavigationLock/>
component that wraps the new NavigationManager
APIs. It includes an option for showing a browser prompt for confirming "external" navigations. Note that external navigations will not cause "location changing" handlers to get invoked. This is because the beforeunload
event (used to determine if a prompt should display before navigating) must return synchronously, so the result from an asynchronous JS -> .NET callback cannot be utilized.
Note: These changes have already been implemented in #42638. A follow-up PR will address any API review feedback.
Note: All of the following APIs are purely additive. I didn't use the + ...
diff format because then you don't get syntax highlighting. NavigationManager
is an existing class and all of the members listed here are new. The other two classes are entirely new.
namespace Microsoft.AspNetCore.Components;
public abstract class NavigationManager
{
/// <summary>
/// Adds a handler to process incoming navigation events.
/// </summary>
/// <param name="locationChangingHandler">The handler to process incoming navigation events.</param>
public void AddLocationChangingHandler(Func<LocationChangingContext, ValueTask> locationChangingHandler);
/// <summary>
/// Removes a previously-added location changing handler.
/// </summary>
/// <param name="locationChangingHandler">The handler to remove.</param>
public void RemoveLocationChangingHandler(Func<LocationChangingContext, ValueTask> locationChangingHandler);
/// <summary>
/// Notifies the registered handlers of the current location change.
/// </summary>
/// <param name="uri">The destination URI. This can be absolute, or relative to the base URI.</param>
/// <param name="state">The state associated with the target history entry.</param>
/// <param name="isNavigationIntercepted">Whether this navigation was intercepted from a link.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> representing the completion of the operation. If the result is <see langword="true"/>, the navigation should continue.</returns>
protected ValueTask<bool> NotifyLocationChangingAsync(string uri, string? state, bool isNavigationIntercepted);
/// <summary>
/// Invokes the provided <paramref name="handler"/>, passing it the given <paramref name="context"/>.
/// This method can be overridden to analyze the state of the handler task even after
/// <see cref="NotifyLocationChangingAsync(string, string?, bool)"/> completes. For example, this can be useful for
/// processing exceptions thrown from handlers that continue running after the navigation ends.
/// </summary>
/// <param name="handler">The handler to invoke.</param>
/// <param name="context">The context to pass to the handler.</param>
/// <returns></returns>
protected virtual ValueTask InvokeLocationChangingHandlerAsync(Func<LocationChangingContext, ValueTask> handler, LocationChangingContext context);
/// <summary>
/// Sets whether navigation is currently locked. If it is, then implementations should not update <see cref="Uri"/> and call
/// <see cref="NotifyLocationChanged(bool)"/> until they have first confirmed the navigation by calling
/// <see cref="NotifyLocationChangingAsync(string, string?, bool)"/>.
/// </summary>
/// <param name="value">Whether navigation is currently locked.</param>
protected virtual void SetNavigationLockState(bool value);
}
namespace Microsoft.AspNetCore.Components.Routing;
/// <summary>
/// Contains context for a change to the browser's current location.
/// </summary>
public class LocationChangingContext
{
/// <summary>
/// Gets the target location.
/// </summary>
public string TargetLocation { get; }
/// <summary>
/// Gets the state associated with the target history entry.
/// </summary>
public string? HistoryEntryState { get; }
/// <summary>
/// Gets whether this navigation was intercepted from a link.
/// </summary>
public bool IsNavigationIntercepted { get; }
/// <summary>
/// Gets a <see cref="System.Threading.CancellationToken"/> that can be used to determine if this navigation was canceled
/// (for example, because the user has triggered a different navigation).
/// </summary>
public CancellationToken CancellationToken { get; }
/// <summary>
/// Prevents this navigation from continuing.
/// </summary>
public void PreventNavigation();
}
/// <summary>
/// A component that can be used to intercept navigation events.
/// </summary>
public class NavigationLock : IComponent, IAsyncDisposable
{
/// <summary>
/// Gets or sets a callback to be invoked when an internal navigation event occurs.
/// </summary>
public EventCallback<LocationChangingContext> OnBeforeInternalNavigation { get; set; }
/// <summary>
/// Gets or sets whether a browser dialog should prompt the user to either confirm or cancel
/// external navigations.
/// </summary>
public bool ConfirmExternalNavigation { get; set; }
}
Usage Examples
<NavigationLock/>
Component
@inject IJSRuntime JSRuntime
<NavigationLock OnBeforeInternalNavigation="OnBeforeInternalNavigation" ConfirmExternalNavigation="true" />
@code {
private async Task OnBeforeInternalNavigation(LocationChangingContext context)
{
// This just displays the built-in browser confirm dialog, but you can display a custom prompt
// for internal navigations if you want.
var isConfirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Are you sure you want to continue?");
if (!isConfirmed)
{
context.PreventNavigation();
}
}
}
NavigationManager
APIs
Useful in cases where the lifetime of a navigation lock is not constrained to the lifetime of a specific Blazor component.
Risks
The implementation of this feature is quite involved (see #42638), and its asynchronous nature makes handling cases like overlapping navigations very non-trivial. However, we have ample test coverage for these scenarios, so we're reasonably confident in what we have. Code that doesn't use this feature won't be impacted by its existence; the entire "location changing" step is skipped unless there are handlers registered.