Skip to content

Commit

Permalink
Allow cancellation of navigation events in Blazor (#42638)
Browse files Browse the repository at this point in the history
  • Loading branch information
MackinnonBuck authored Jul 22, 2022
1 parent 6ad8ac4 commit de68fea
Show file tree
Hide file tree
Showing 36 changed files with 2,280 additions and 29 deletions.
177 changes: 177 additions & 0 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using Microsoft.AspNetCore.Components.Routing;

namespace Microsoft.AspNetCore.Components;
Expand Down Expand Up @@ -31,6 +32,10 @@ public event EventHandler<LocationChangedEventArgs> LocationChanged

private EventHandler<LocationChangedEventArgs>? _locationChanged;

private readonly List<Func<LocationChangingContext, ValueTask>> _locationChangingHandlers = new();

private CancellationTokenSource? _locationChangingCts;

// For the baseUri it's worth storing as a System.Uri so we can do operations
// on that type. System.Uri gives us access to the original string anyway.
private Uri? _baseUri;
Expand Down Expand Up @@ -274,6 +279,178 @@ protected void NotifyLocationChanged(bool isInterceptedLink)
}
}

/// <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 async ValueTask<bool> NotifyLocationChangingAsync(string uri, string? state, bool isNavigationIntercepted)
{
_locationChangingCts?.Cancel();
_locationChangingCts = null;

var handlerCount = _locationChangingHandlers.Count;

if (handlerCount == 0)
{
return true;
}

var cts = new CancellationTokenSource();

_locationChangingCts = cts;

var cancellationToken = cts.Token;
var context = new LocationChangingContext(uri, state, isNavigationIntercepted, cancellationToken);

try
{
if (handlerCount == 1)
{
var handlerTask = InvokeLocationChangingHandlerAsync(_locationChangingHandlers[0], context);

if (context.DidPreventNavigation)
{
return false;
}

if (!handlerTask.IsCompletedSuccessfully)
{
await handlerTask.AsTask().WaitAsync(cancellationToken);
}
}
else
{
var locationChangingHandlersCopy = ArrayPool<Func<LocationChangingContext, ValueTask>>.Shared.Rent(handlerCount);

try
{
_locationChangingHandlers.CopyTo(locationChangingHandlersCopy);

var locationChangingTasks = new HashSet<Task>();

for (var i = 0; i < handlerCount; i++)
{
var handlerTask = InvokeLocationChangingHandlerAsync(locationChangingHandlersCopy[i], context);

if (handlerTask.IsFaulted)
{
await handlerTask;
return false; // Unreachable because the previous line will throw.
}

if (context.DidPreventNavigation)
{
return false;
}

locationChangingTasks.Add(handlerTask.AsTask());
}

while (locationChangingTasks.Count != 0)
{
var completedHandlerTask = await Task.WhenAny(locationChangingTasks).WaitAsync(cancellationToken);

if (completedHandlerTask.IsFaulted)
{
await completedHandlerTask;
return false; // Unreachable because the previous line will throw.
}

if (context.DidPreventNavigation)
{
return false;
}

locationChangingTasks.Remove(completedHandlerTask);
}
}
finally
{
ArrayPool<Func<LocationChangingContext, ValueTask>>.Shared.Return(locationChangingHandlersCopy);
}
}

return !context.DidPreventNavigation;
}
catch (TaskCanceledException ex)
{
if (ex.CancellationToken == cancellationToken)
{
// This navigation was in progress when a successive navigation occurred.
// We treat this as a canceled navigation.
return false;
}

throw;
}
finally
{
cts.Cancel();
cts.Dispose();

if (_locationChangingCts == cts)
{
_locationChangingCts = null;
}
}
}

/// <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)
=> handler(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)
=> throw new NotSupportedException($"To support navigation locks, {GetType().Name} must override {nameof(SetNavigationLockState)}");

/// <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)
{
AssertInitialized();

var isFirstHandler = _locationChangingHandlers.Count == 0;

_locationChangingHandlers.Add(locationChangingHandler);

if (isFirstHandler)
{
SetNavigationLockState(true);
}
}

/// <summary>
/// Removes a previously-added location changing handler.
/// </summary>
/// <param name="locationChangingHandler">The handler to remove.</param>
public void RemoveLocationChangingHandler(Func<LocationChangingContext, ValueTask> locationChangingHandler)
{
AssertInitialized();

if (_locationChangingHandlers.Remove(locationChangingHandler) && _locationChangingHandlers.Count == 0)
{
SetNavigationLockState(false);
}
}

private void AssertInitialized()
{
if (!_isInitialized)
Expand Down
11 changes: 11 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationManager.AddLocationChangingHandler(System.Func<Microsoft.AspNetCore.Components.Routing.LocationChangingContext!, System.Threading.Tasks.ValueTask>! locationChangingHandler) -> void
Microsoft.AspNetCore.Components.NavigationManager.HistoryEntryState.get -> string?
Microsoft.AspNetCore.Components.NavigationManager.HistoryEntryState.set -> void
Microsoft.AspNetCore.Components.NavigationManager.NotifyLocationChangingAsync(string! uri, string? state, bool isNavigationIntercepted) -> System.Threading.Tasks.ValueTask<bool>
Microsoft.AspNetCore.Components.NavigationManager.RemoveLocationChangingHandler(System.Func<Microsoft.AspNetCore.Components.Routing.LocationChangingContext!, System.Threading.Tasks.ValueTask>! locationChangingHandler) -> void
Microsoft.AspNetCore.Components.NavigationOptions.HistoryEntryState.get -> string?
Microsoft.AspNetCore.Components.NavigationOptions.HistoryEntryState.init -> void
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddContent(int sequence, Microsoft.AspNetCore.Components.MarkupString? markupContent) -> void
Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs.HistoryEntryState.get -> string?
Microsoft.AspNetCore.Components.Routing.LocationChangingContext
Microsoft.AspNetCore.Components.Routing.LocationChangingContext.CancellationToken.get -> System.Threading.CancellationToken
Microsoft.AspNetCore.Components.Routing.LocationChangingContext.HistoryEntryState.get -> string?
Microsoft.AspNetCore.Components.Routing.LocationChangingContext.IsNavigationIntercepted.get -> bool
Microsoft.AspNetCore.Components.Routing.LocationChangingContext.PreventNavigation() -> void
Microsoft.AspNetCore.Components.Routing.LocationChangingContext.TargetLocation.get -> string!
static Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback<T>(object! receiver, Microsoft.AspNetCore.Components.EventCallback<T> callback, T value) -> Microsoft.AspNetCore.Components.EventCallback<T>
static Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.InvokeAsynchronousDelegate(System.Action! callback) -> System.Threading.Tasks.Task!
static Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.InvokeAsynchronousDelegate(System.Func<System.Threading.Tasks.Task!>! callback) -> System.Threading.Tasks.Task!
Expand Down Expand Up @@ -41,3 +50,5 @@ static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.Crea
static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory! factory, object! receiver, Microsoft.AspNetCore.Components.EventCallback<short?> setter, short? existingValue, System.Globalization.CultureInfo? culture = null) -> Microsoft.AspNetCore.Components.EventCallback<Microsoft.AspNetCore.Components.ChangeEventArgs!>
static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory! factory, object! receiver, Microsoft.AspNetCore.Components.EventCallback<string?> setter, string! existingValue, System.Globalization.CultureInfo? culture = null) -> Microsoft.AspNetCore.Components.EventCallback<Microsoft.AspNetCore.Components.ChangeEventArgs!>
static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.CreateBinder<T>(this Microsoft.AspNetCore.Components.EventCallbackFactory! factory, object! receiver, Microsoft.AspNetCore.Components.EventCallback<T> setter, T existingValue, System.Globalization.CultureInfo? culture = null) -> Microsoft.AspNetCore.Components.EventCallback<Microsoft.AspNetCore.Components.ChangeEventArgs!>
virtual Microsoft.AspNetCore.Components.NavigationManager.InvokeLocationChangingHandlerAsync(System.Func<Microsoft.AspNetCore.Components.Routing.LocationChangingContext!, System.Threading.Tasks.ValueTask>! handler, Microsoft.AspNetCore.Components.Routing.LocationChangingContext! context) -> System.Threading.Tasks.ValueTask
virtual Microsoft.AspNetCore.Components.NavigationManager.SetNavigationLockState(bool value) -> void
49 changes: 49 additions & 0 deletions src/Components/Components/src/Routing/LocationChangingContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Routing;

/// <summary>
/// Contains context for a change to the browser's current location.
/// </summary>
public class LocationChangingContext
{
internal LocationChangingContext(string targetLocation, string? historyEntryState, bool isNavigationIntercepted, CancellationToken cancellationToken)
{
TargetLocation = targetLocation;
HistoryEntryState = historyEntryState;
IsNavigationIntercepted = isNavigationIntercepted;
CancellationToken = cancellationToken;
}

internal bool DidPreventNavigation { get; private set; }

/// <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()
{
DidPreventNavigation = true;
}
}
Loading

0 comments on commit de68fea

Please sign in to comment.