Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,15 +256,26 @@ public Uri ToAbsoluteUri(string? relativeUri)
return new Uri(_baseUri!, relativeUri);
}

/// <summary>Holds the active <see cref="StringComparison"/> used for base URI matching.</summary>
private StringComparison _pathBaseComparison = StringComparison.Ordinal;

/// <summary>
/// Sets the string comparison used for base URI matching.
/// </summary>
protected internal StringComparison PathBaseComparison
{
Comment on lines +264 to +266
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PathBaseComparison property lacks a getter, making it write-only. This prevents users from inspecting the current configuration and makes testing/debugging more difficult. Consider adding get => to make this property read-write, which would align with typical options patterns and allow verification of the configured value.

Suggested change
/// </summary>
protected internal StringComparison PathBaseComparison
{
/// </summary>
/// <summary>
/// Gets or sets the string comparison used for base URI matching.
/// </summary>
protected internal StringComparison PathBaseComparison
{
get => _pathBaseComparison;

Copilot uses AI. Check for mistakes.
set => _pathBaseComparison = value;
}
Comment on lines +262 to +268
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XML documentation is missing for the PathBaseComparison property setter. Since this is part of the public API surface (as evidenced by PublicAPI.Unshipped.txt), it should include XML doc comments explaining when and how this property is used. Consider adding documentation similar to: /// <summary>Sets the string comparison used for base URI matching. This is typically configured via <see cref="NavigationManagerOptions.PathBaseComparison"/>.</summary>

Copilot uses AI. Check for mistakes.

/// <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>
/// <returns>The portion of <paramref name="uri"/> that follows the <see cref="BaseUri"/> prefix.</returns>
public string ToBaseRelativePath(string uri)
{
if (uri.StartsWith(_baseUri!.OriginalString, StringComparison.Ordinal))
if (uri.StartsWith(_baseUri!.OriginalString, _pathBaseComparison))
{
// The absolute URI must be of the form "{baseUri}something" (where
// baseUri ends with a slash), and from that we return "something"
Expand All @@ -273,7 +284,7 @@ public string ToBaseRelativePath(string uri)

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))
if (_baseUri.OriginalString.EndsWith('/') && uriPathOnly.Equals(_baseUri.OriginalString.AsSpan(0, _baseUri.OriginalString.Length - 1), _pathBaseComparison))
{
// Special case: for the base URI "/something/", if you're at
// "/something" then treat it as if you were at "/something/" (i.e.,
Expand All @@ -290,7 +301,7 @@ public string ToBaseRelativePath(string uri)

internal ReadOnlySpan<char> ToBaseRelativePath(ReadOnlySpan<char> uri)
{
if (MemoryExtensions.StartsWith(uri, _baseUri!.OriginalString.AsSpan(), StringComparison.Ordinal))
if (MemoryExtensions.StartsWith(uri, _baseUri!.OriginalString.AsSpan(), _pathBaseComparison))
{
// The absolute URI must be of the form "{baseUri}something" (where
// baseUri ends with a slash), and from that we return "something"
Expand All @@ -299,7 +310,7 @@ internal ReadOnlySpan<char> ToBaseRelativePath(ReadOnlySpan<char> uri)

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))
if (_baseUri.OriginalString.EndsWith('/') && MemoryExtensions.Equals(uriPathOnly, _baseUri.OriginalString.AsSpan(0, _baseUri.OriginalString.Length - 1), _pathBaseComparison))
{
// Special case: for the base URI "/something/", if you're at
// "/something" then treat it as if you were at "/something/" (i.e.,
Expand Down Expand Up @@ -553,9 +564,9 @@ private void AssertInitialized()
}
}

private static bool TryGetLengthOfBaseUriPrefix(Uri baseUri, string uri, out int length)
private bool TryGetLengthOfBaseUriPrefix(Uri baseUri, string uri, out int length)
{
if (uri.StartsWith(baseUri.OriginalString, StringComparison.Ordinal))
if (uri.StartsWith(baseUri.OriginalString, _pathBaseComparison))
{
// The absolute URI must be of the form "{baseUri}something" (where
// baseUri ends with a slash), and from that we return "something"
Expand All @@ -565,7 +576,7 @@ private static bool TryGetLengthOfBaseUriPrefix(Uri baseUri, string uri, out int

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))
if (baseUri.OriginalString.EndsWith('/') && uriPathOnly.Equals(baseUri.OriginalString.AsSpan(0, baseUri.OriginalString.Length - 1), _pathBaseComparison))
{
// Special case: for the base URI "/something/", if you're at
// "/something" then treat it as if you were at "/something/" (i.e.,
Expand All @@ -581,7 +592,7 @@ private static bool TryGetLengthOfBaseUriPrefix(Uri baseUri, string uri, out int
return false;
}

private static void Validate(Uri? baseUri, string uri)
private void Validate(Uri? baseUri, string uri)
{
if (baseUri == null || uri == null)
{
Expand Down
17 changes: 17 additions & 0 deletions src/Components/Components/src/NavigationManagerOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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;

/// <summary>
/// Provides configuration for <see cref="NavigationManager"/> behavior.
/// </summary>
public class NavigationManagerOptions
{
/// <summary>
/// Gets or sets the string comparison used when comparing URIs against the base URI.
/// The default is <see cref="System.StringComparison.Ordinal"/> for backward compatibility.
/// Set to <see cref="System.StringComparison.OrdinalIgnoreCase"/> to enable case-insensitive matching.
/// </summary>
public StringComparison PathBaseComparison { get; set; } = StringComparison.Ordinal;
}
5 changes: 5 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationManagerOptions
Microsoft.AspNetCore.Components.NavigationManagerOptions.NavigationManagerOptions() -> void
Microsoft.AspNetCore.Components.NavigationManagerOptions.PathBaseComparison.get -> System.StringComparison
Microsoft.AspNetCore.Components.NavigationManagerOptions.PathBaseComparison.set -> void
Microsoft.AspNetCore.Components.NavigationManager.PathBaseComparison.set -> void
21 changes: 21 additions & 0 deletions src/Components/Components/test/NavigationManagerTest.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;
using System.Buffers;
using System.Diagnostics;
using System.Net.Http;
Expand Down Expand Up @@ -84,6 +85,21 @@ public void ToBaseRelativePath_ThrowsForInvalidBaseRelativePaths(string baseUri,
ex.Message);
}

[Fact]
public void ToBaseRelativePath_HonorsConfiguredPathBaseComparison()
{
var navigationManager = new TestNavigationManager("https://example.com/dashboard/", "https://example.com/dashboard/");

var ex = Assert.Throws<ArgumentException>(() => navigationManager.ToBaseRelativePath("https://example.com/DaShBoArD"));
Assert.Equal("The URI 'https://example.com/DaShBoArD' is not contained by the base URI 'https://example.com/dashboard/'.", ex.Message);

navigationManager.SetPathBaseComparison(StringComparison.OrdinalIgnoreCase);

var result = navigationManager.ToBaseRelativePath("https://example.com/DaShBoArD");

Assert.Equal(string.Empty, result);
}
Comment on lines +88 to +101
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is incomplete for the case-insensitive path matching feature. The test only covers the basic scenario where the path matches exactly. Consider adding test cases for:

  1. The trailing slash special case with case-insensitive matching (e.g., base URI "/App/" matching "/app")
  2. URIs with query strings and fragments using case-insensitive matching (e.g., "/App?query=1" with base URI "/app/")
  3. The internal ReadOnlySpan<char> overload of ToBaseRelativePath with case-insensitive matching
  4. The Validate method behavior with case-insensitive comparison during initialization

Copilot uses AI. Check for mistakes.

[Theory]
[InlineData("scheme://host/?full%20name=Bob%20Joe&age=42", "scheme://host/?full%20name=John%20Doe&age=42")]
[InlineData("scheme://host/?fUlL%20nAmE=Bob%20Joe&AgE=42", "scheme://host/?full%20name=John%20Doe&AgE=42")]
Expand Down Expand Up @@ -906,6 +922,11 @@ public TestNavigationManager(string baseUri = null, string uri = null)
public async Task<bool> RunNotifyLocationChangingAsync(string uri, string state, bool isNavigationIntercepted)
=> await NotifyLocationChangingAsync(uri, state, isNavigationIntercepted);

public void SetPathBaseComparison(StringComparison comparison)
{
PathBaseComparison = comparison;
}

protected override void NavigateToCore(string uri, bool forceLoad)
{
throw new System.NotImplementedException();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Routing;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.Extensions.Options;

namespace Microsoft.AspNetCore.Components.Endpoints;

internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager
{
public HttpNavigationManager(IOptions<NavigationManagerOptions> options)
{
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null argument validation. The options parameter should be validated for null before dereferencing options.Value. Add ArgumentNullException.ThrowIfNull(options); at the start of the constructor to prevent potential NullReferenceException.

Suggested change
{
{
ArgumentNullException.ThrowIfNull(options);

Copilot uses AI. Check for mistakes.
PathBaseComparison = options.Value.PathBaseComparison;
}

private const string _disableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.NavigationManager.DisableThrowNavigationException";

[FeatureSwitchDefinition(_disableThrowNavigationException)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection
// Dependencies
services.AddLogging();
services.AddAntiforgery();
services.AddOptions<NavigationManagerOptions>();

services.TryAddSingleton<RazorComponentsMarkerService>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using Interop = Microsoft.AspNetCore.Components.Web.BrowserNavigationManagerInterop;

Expand All @@ -30,9 +31,11 @@ internal sealed partial class RemoteNavigationManager : NavigationManager, IHost
/// Creates a new <see cref="RemoteNavigationManager"/> instance.
/// </summary>
/// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
public RemoteNavigationManager(ILogger<RemoteNavigationManager> logger)
/// <param name="options">The configured <see cref="NavigationManagerOptions"/>.</param>
public RemoteNavigationManager(ILogger<RemoteNavigationManager> logger, IOptions<NavigationManagerOptions> options)
{
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null argument validation. The options parameter should be validated for null before dereferencing options.Value. Add ArgumentNullException.ThrowIfNull(options); at the start of the constructor to prevent potential NullReferenceException.

Suggested change
{
{
ArgumentNullException.ThrowIfNull(options);

Copilot uses AI. Check for mistakes.
_logger = logger;
PathBaseComparison = options.Value.PathBaseComparison;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti
var builder = new DefaultServerSideBlazorBuilder(services);

services.AddDataProtection();
services.AddOptions<NavigationManagerOptions>();

services.TryAddScoped<ProtectedLocalStorage>();
services.TryAddScoped<ProtectedSessionStorage>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting;

Expand Down Expand Up @@ -53,6 +54,12 @@ internal WebAssemblyHost(
_configuration = builder.Configuration;
_rootComponents = builder.RootComponents;
_persistedState = persistedState;

var navigationManagerOptions = services.GetService<IOptions<NavigationManagerOptions>>();
if (navigationManagerOptions is not null)
{
WebAssemblyNavigationManager.Instance.ApplyOptions(navigationManagerOptions.Value);
}
Comment on lines +58 to +62
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition: Options are applied to the WebAssemblyNavigationManager.Instance singleton after it's been registered in DI (line 322 of WebAssemblyHostBuilder.cs). If any service resolved during scope creation (line 313) accesses the NavigationManager, it will use the default StringComparison.Ordinal instead of the configured value. Consider applying options immediately after registering the NavigationManager in InitializeDefaultServices(), or document that NavigationManager should not be accessed during service construction.

Copilot uses AI. Check for mistakes.
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ public WebAssemblyHost Build()
internal void InitializeDefaultServices()
{
Services.AddSingleton<IJSRuntime>(DefaultWebAssemblyJSRuntime.Instance);
Services.AddOptions<NavigationManagerOptions>();
Services.AddSingleton<NavigationManager>(WebAssemblyNavigationManager.Instance);
Services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
Services.AddSingleton<IScrollToLocationHash>(WebAssemblyScrollToLocationHash.Instance);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public WebAssemblyNavigationManager(string baseUri, string uri)
Initialize(baseUri, uri);
}

public void ApplyOptions(NavigationManagerOptions options)
{
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null argument validation. The options parameter should be validated for null before dereferencing options.PathBaseComparison. Add ArgumentNullException.ThrowIfNull(options); at the start of the method to prevent potential NullReferenceException.

Suggested change
{
{
ArgumentNullException.ThrowIfNull(options);

Copilot uses AI. Check for mistakes.
PathBaseComparison = options.PathBaseComparison;
}

public void CreateLogger(ILoggerFactory loggerFactory)
{
if (_logger is not null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

namespace HostedInAspNet.Server;

using System;
using Microsoft.AspNetCore.Components;
Comment on lines +6 to +7
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using directives are placed inside the namespace declaration. According to the repository's coding guidelines (from .editorconfig), prefer placing using directives outside namespace declarations for consistency. Move lines 6-7 above line 4.

Copilot generated this review using guidance from repository custom instructions.

public class Startup
{
public Startup(IConfiguration configuration)
Expand All @@ -16,6 +19,14 @@ public Startup(IConfiguration configuration)
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
var mapAlternativePathApp = Configuration.GetValue<bool>("UseAlternativeBasePath");
if (mapAlternativePathApp)
{
services.Configure<NavigationManagerOptions>(options =>
{
options.PathBaseComparison = StringComparison.OrdinalIgnoreCase;
});
}
services.AddSingleton<BootResourceRequestLog>();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETest.Tests;
Expand All @@ -22,15 +23,14 @@ public HostedInAlternativeBasePathTest(
serverFixture.Environment = AspNetEnvironment.Development;
}

protected override void InitializeAsyncCore()
[Theory]
[InlineData("/app/")]
[InlineData("/APP/")]
public void CanLoadBlazorAppFromSubPath(string path)
{
Navigate("/app/");
Navigate(path);
WaitUntilLoaded();
}

[Fact]
public void CanLoadBlazorAppFromSubPath()
{
Assert.Equal("App loaded on custom path", Browser.Title);
Assert.Empty(Browser.GetBrowserLogs(LogLevel.Severe));
}
Expand Down
Loading