File: NavigationManager.cs
Web Access
Project: src\src\Components\Components\src\Microsoft.AspNetCore.Components.csproj (Microsoft.AspNetCore.Components)
// 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 System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components.Routing;
 
namespace Microsoft.AspNetCore.Components;
 
/// <summary>
/// Provides an abstraction for querying and managing URI navigation.
/// </summary>
public abstract class NavigationManager
{
    /// <summary>
    /// An event that fires when the navigation location has changed.
    /// </summary>
    public event EventHandler<LocationChangedEventArgs> LocationChanged
    {
        add
        {
            AssertInitialized();
            _locationChanged += value;
        }
        remove
        {
            AssertInitialized();
            _locationChanged -= value;
        }
    }
 
    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;
 
    // The URI. Always represented an absolute URI.
    private string? _uri;
    private bool _isInitialized;
 
    /// <summary>
    /// Gets or sets the current base URI. The <see cref="BaseUri" /> is always represented as an absolute URI in string form with trailing slash.
    /// Typically this corresponds to the 'href' attribute on the document's &lt;base&gt; element.
    /// </summary>
    /// <remarks>
    /// Setting <see cref="BaseUri" /> will not trigger the <see cref="LocationChanged" /> event.
    /// </remarks>
    public string BaseUri
    {
        get
        {
            AssertInitialized();
            return _baseUri!.OriginalString;
        }
        protected set
        {
            if (value != null)
            {
                value = NormalizeBaseUri(value);
            }
 
            _baseUri = new Uri(value!, UriKind.Absolute);
        }
    }
 
    /// <summary>
    /// Gets or sets the current URI. The <see cref="Uri" /> is always represented as an absolute URI in string form.
    /// </summary>
    /// <remarks>
    /// Setting <see cref="Uri" /> will not trigger the <see cref="LocationChanged" /> event.
    /// </remarks>
    public string Uri
    {
        get
        {
            AssertInitialized();
            return _uri!;
        }
        protected set
        {
            Validate(_baseUri, value);
            _uri = value;
        }
    }
 
    /// <summary>
    /// Gets or sets the state associated with the current navigation.
    /// </summary>
    /// <remarks>
    /// Setting <see cref="HistoryEntryState" /> will not trigger the <see cref="LocationChanged" /> event.
    /// </remarks>
    public string? HistoryEntryState { get; protected set; }
 
    /// <summary>
    /// Navigates to the specified URI.
    /// </summary>
    /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
    /// (as returned by <see cref="BaseUri"/>).</param>
    /// <param name="forceLoad">If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.</param>
    public void NavigateTo([StringSyntax(StringSyntaxAttribute.Uri)] string uri, bool forceLoad) // This overload is for binary back-compat with < 6.0
        => NavigateTo(uri, forceLoad, replace: false);
 
    /// <summary>
    /// Navigates to the specified URI.
    /// </summary>
    /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
    /// (as returned by <see cref="BaseUri"/>).</param>
    /// <param name="forceLoad">If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.</param>
    /// <param name="replace">If true, replaces the current entry in the history stack. If false, appends the new entry to the history stack.</param>
    public void NavigateTo([StringSyntax(StringSyntaxAttribute.Uri)] string uri, bool forceLoad = false, bool replace = false)
    {
        AssertInitialized();
 
        if (replace)
        {
            NavigateToCore(uri, new NavigationOptions
            {
                ForceLoad = forceLoad,
                ReplaceHistoryEntry = replace,
            });
        }
        else
        {
            // For back-compatibility, we must call the (string, bool) overload of NavigateToCore from here,
            // because that's the only overload guaranteed to be implemented in subclasses.
            NavigateToCore(uri, forceLoad);
        }
    }
 
    /// <summary>
    /// Navigates to the specified URI.
    /// </summary>
    /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
    /// (as returned by <see cref="BaseUri"/>).</param>
    /// <param name="options">Provides additional <see cref="NavigationOptions"/>.</param>
    public void NavigateTo([StringSyntax(StringSyntaxAttribute.Uri)] string uri, NavigationOptions options)
    {
        AssertInitialized();
        NavigateToCore(uri, options);
    }
 
    /// <summary>
    /// Navigates to the specified URI.
    /// </summary>
    /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
    /// (as returned by <see cref="BaseUri"/>).</param>
    /// <param name="forceLoad">If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.</param>
    // The reason this overload exists and is virtual is for back-compat with < 6.0. Existing NavigationManager subclasses may
    // already override this, so the framework needs to keep using it for the cases when only pre-6.0 options are used.
    // However, for anyone implementing a new NavigationManager post-6.0, we don't want them to have to override this
    // overload any more, so there's now a default implementation that calls the updated overload.
    protected virtual void NavigateToCore([StringSyntax(StringSyntaxAttribute.Uri)] string uri, bool forceLoad)
        => NavigateToCore(uri, new NavigationOptions { ForceLoad = forceLoad });
 
    /// <summary>
    /// Navigates to the specified URI.
    /// </summary>
    /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
    /// (as returned by <see cref="BaseUri"/>).</param>
    /// <param name="options">Provides additional <see cref="NavigationOptions"/>.</param>
    protected virtual void NavigateToCore([StringSyntax(StringSyntaxAttribute.Uri)] string uri, NavigationOptions options) =>
        throw new NotImplementedException($"The type {GetType().FullName} does not support supplying {nameof(NavigationOptions)}. To add support, that type should override {nameof(NavigateToCore)}(string uri, {nameof(NavigationOptions)} options).");
 
    /// <summary>
    /// Refreshes the current page via request to the server.
    /// </summary>
    /// <remarks>
    /// If <paramref name="forceReload"/> is <c>true</c>, a full page reload will always be performed.
    /// Otherwise, the response HTML may be merged with the document's existing HTML to preserve client-side state,
    /// falling back on a full page reload if necessary.
    /// </remarks>
    public virtual void Refresh(bool forceReload = false)
        => NavigateTo(Uri, forceLoad: true, replace: true);
 
    /// <summary>
    /// Called to initialize BaseURI and current URI before these values are used for the first time.
    /// Override <see cref="EnsureInitialized" /> and call this method to dynamically calculate these values.
    /// </summary>
    protected void Initialize(string baseUri, string uri)
    {
        // Make sure it's possible/safe to call this method from constructors of derived classes.
        ArgumentNullException.ThrowIfNull(uri);
        ArgumentNullException.ThrowIfNull(baseUri);
 
        if (_isInitialized)
        {
            throw new InvalidOperationException($"'{GetType().Name}' already initialized.");
        }
 
        _isInitialized = true;
 
        // Setting BaseUri before Uri so they get validated.
        BaseUri = baseUri;
        Uri = uri;
    }
 
    /// <summary>
    /// Allows derived classes to lazily self-initialize. Implementations that support lazy-initialization should override
    /// this method and call <see cref="Initialize(string, string)" />.
    /// </summary>
    protected virtual void EnsureInitialized()
    {
    }
 
    /// <summary>
    /// Converts a relative URI into an absolute one (by resolving it
    /// relative to the current absolute URI).
    /// </summary>
    /// <param name="relativeUri">The relative URI.</param>
    /// <returns>The absolute URI.</returns>
    public Uri ToAbsoluteUri(string? relativeUri)
    {
        AssertInitialized();
        return new Uri(_baseUri!, relativeUri);
    }
 
    /// <summary>
    /// Given a base URI (e.g., one previously returned by <see cref="BaseUri"/>),
    /// converts an absolute URI into one relative to the base URI prefix.
    /// </summary>
    /// <param name="uri">An absolute URI that is within the space of the base URI.</param>
    /// <returns>A relative URI path.</returns>
    public string ToBaseRelativePath(string uri)
    {
        if (uri.StartsWith(_baseUri!.OriginalString, StringComparison.Ordinal))
        {
            // The absolute URI must be of the form "{baseUri}something" (where
            // baseUri ends with a slash), and from that we return "something"
            return uri.Substring(_baseUri.OriginalString.Length);
        }
 
        var pathEndIndex = uri.AsSpan().IndexOfAny('#', '?');
        var uriPathOnly = pathEndIndex < 0 ? uri : uri.AsSpan(0, pathEndIndex);
        if (_baseUri.OriginalString.EndsWith('/') && uriPathOnly.Equals(_baseUri.OriginalString.AsSpan(0, _baseUri.OriginalString.Length - 1), StringComparison.Ordinal))
        {
            // Special case: for the base URI "/something/", if you're at
            // "/something" then treat it as if you were at "/something/" (i.e.,
            // with the trailing slash). It's a bit ambiguous because we don't know
            // whether the server would return the same page whether or not the
            // slash is present, but ASP.NET Core at least does by default when
            // using PathBase.
            return uri.Substring(_baseUri.OriginalString.Length - 1);
        }
 
        var message = $"The URI '{uri}' is not contained by the base URI '{_baseUri}'.";
        throw new ArgumentException(message);
    }
 
    internal ReadOnlySpan<char> ToBaseRelativePath(ReadOnlySpan<char> uri)
    {
        if (MemoryExtensions.StartsWith(uri, _baseUri!.OriginalString.AsSpan(), StringComparison.Ordinal))
        {
            // The absolute URI must be of the form "{baseUri}something" (where
            // baseUri ends with a slash), and from that we return "something"
            return uri[_baseUri.OriginalString.Length..];
        }
 
        var pathEndIndex = uri.IndexOfAny('#', '?');
        var uriPathOnly = pathEndIndex < 0 ? uri : uri[..pathEndIndex];
        if (_baseUri.OriginalString.EndsWith('/') && MemoryExtensions.Equals(uriPathOnly, _baseUri.OriginalString.AsSpan(0, _baseUri.OriginalString.Length - 1), StringComparison.Ordinal))
        {
            // Special case: for the base URI "/something/", if you're at
            // "/something" then treat it as if you were at "/something/" (i.e.,
            // with the trailing slash). It's a bit ambiguous because we don't know
            // whether the server would return the same page whether or not the
            // slash is present, but ASP.NET Core at least does by default when
            // using PathBase.
            return uri[(_baseUri.OriginalString.Length - 1)..];
        }
 
        var message = $"The URI '{uri}' is not contained by the base URI '{_baseUri}'.";
        throw new ArgumentException(message);
    }
 
    internal static string NormalizeBaseUri(string baseUri)
    {
        var lastSlashIndex = baseUri.LastIndexOf('/');
        if (lastSlashIndex >= 0)
        {
            baseUri = baseUri.Substring(0, lastSlashIndex + 1);
        }
 
        return baseUri;
    }
 
    /// <summary>
    /// Triggers the <see cref="LocationChanged"/> event with the current URI value.
    /// </summary>
    protected void NotifyLocationChanged(bool isInterceptedLink)
    {
        try
        {
            _locationChanged?.Invoke(
                this,
                new LocationChangedEventArgs(_uri!, isInterceptedLink)
                {
                    HistoryEntryState = HistoryEntryState
                });
        }
        catch (Exception ex)
        {
            throw new LocationChangeException("An exception occurred while dispatching a location changed event.", ex);
        }
    }
 
    /// <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
        {
            TargetLocation = uri,
            HistoryEntryState = state,
            IsNavigationIntercepted = isNavigationIntercepted,
            CancellationToken = cancellationToken,
        };
 
        try
        {
            if (handlerCount == 1)
            {
                var handlerTask = InvokeLocationChangingHandlerAsync(_locationChangingHandlers[0], context);
 
                if (handlerTask.IsFaulted)
                {
                    await handlerTask;
                    return false; // Unreachable because the previous line will throw.
                }
 
                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;
            }
        }
    }
 
    private async ValueTask InvokeLocationChangingHandlerAsync(Func<LocationChangingContext, ValueTask> handler, LocationChangingContext context)
    {
        try
        {
            await handler(context);
        }
        catch (OperationCanceledException)
        {
            // Ignore exceptions caused by cancellations.
        }
        catch (Exception ex)
        {
            HandleLocationChangingHandlerException(ex, context);
        }
    }
 
    /// <summary>
    /// Handles exceptions thrown in location changing handlers.
    /// </summary>
    /// <param name="ex">The exception to handle.</param>
    /// <param name="context">The context passed to the handler.</param>
    protected virtual void HandleLocationChangingHandlerException(Exception ex, LocationChangingContext context)
        => throw new InvalidOperationException($"To support navigation locks, {GetType().Name} must override {nameof(HandleLocationChangingHandlerException)}");
 
    /// <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>
    /// Registers a handler to process incoming navigation events.
    /// </summary>
    /// <param name="locationChangingHandler">The handler to process incoming navigation events.</param>
    /// <returns>An <see cref="IDisposable"/> that can be disposed to unregister the location changing handler.</returns>
    public IDisposable RegisterLocationChangingHandler(Func<LocationChangingContext, ValueTask> locationChangingHandler)
    {
        AssertInitialized();
 
        var isFirstHandler = _locationChangingHandlers.Count == 0;
 
        _locationChangingHandlers.Add(locationChangingHandler);
 
        if (isFirstHandler)
        {
            SetNavigationLockState(true);
        }
 
        return new LocationChangingRegistration(locationChangingHandler, this);
    }
 
    private void RemoveLocationChangingHandler(Func<LocationChangingContext, ValueTask> locationChangingHandler)
    {
        AssertInitialized();
 
        if (_locationChangingHandlers.Remove(locationChangingHandler) && _locationChangingHandlers.Count == 0)
        {
            SetNavigationLockState(false);
        }
    }
 
    private void AssertInitialized()
    {
        if (!_isInitialized)
        {
            EnsureInitialized();
        }
 
        if (!_isInitialized)
        {
            throw new InvalidOperationException($"'{GetType().Name}' has not been initialized.");
        }
    }
 
    private static bool TryGetLengthOfBaseUriPrefix(Uri baseUri, string uri, out int length)
    {
        if (uri.StartsWith(baseUri.OriginalString, StringComparison.Ordinal))
        {
            // The absolute URI must be of the form "{baseUri}something" (where
            // baseUri ends with a slash), and from that we return "something"
            length = baseUri.OriginalString.Length;
            return true;
        }
 
        var pathEndIndex = uri.AsSpan().IndexOfAny('#', '?');
        var uriPathOnly = pathEndIndex < 0 ? uri : uri.AsSpan(0, pathEndIndex);
        if (baseUri.OriginalString.EndsWith('/') && uriPathOnly.Equals(baseUri.OriginalString.AsSpan(0, baseUri.OriginalString.Length - 1), StringComparison.Ordinal))
        {
            // Special case: for the base URI "/something/", if you're at
            // "/something" then treat it as if you were at "/something/" (i.e.,
            // with the trailing slash). It's a bit ambiguous because we don't know
            // whether the server would return the same page whether or not the
            // slash is present, but ASP.NET Core at least does by default when
            // using PathBase.
            length = baseUri.OriginalString.Length - 1;
            return true;
        }
 
        length = 0;
        return false;
    }
 
    private static void Validate(Uri? baseUri, string uri)
    {
        if (baseUri == null || uri == null)
        {
            return;
        }
 
        if (!TryGetLengthOfBaseUriPrefix(baseUri, uri, out _))
        {
            var message = $"The URI '{uri}' is not contained by the base URI '{baseUri}'.";
            throw new ArgumentException(message);
        }
    }
 
    private sealed class LocationChangingRegistration : IDisposable
    {
        private readonly Func<LocationChangingContext, ValueTask> _handler;
        private readonly NavigationManager _navigationManager;
 
        public LocationChangingRegistration(Func<LocationChangingContext, ValueTask> handler, NavigationManager navigationManager)
        {
            _handler = handler;
            _navigationManager = navigationManager;
        }
 
        public void Dispose()
        {
            _navigationManager.RemoveLocationChangingHandler(_handler);
        }
    }
}