Skip to content
Open
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
27 changes: 27 additions & 0 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,36 @@ public void NavigateTo([StringSyntax(StringSyntaxAttribute.Uri)] string uri, boo
public void NavigateTo([StringSyntax(StringSyntaxAttribute.Uri)] string uri, NavigationOptions options)
{
AssertInitialized();

if (options.PathRelative)
{
uri = ResolveRelativeToCurrentPath(uri);
}

NavigateToCore(uri, options);
}

internal string ResolveRelativeToCurrentPath(string relativeUri)
{
var currentUri = _uri!.AsSpan();

// Find the last slash in the path portion (before any query or fragment)
var queryOrFragmentIndex = currentUri.IndexOfAny('?', '#');
var pathOnlyLength = queryOrFragmentIndex >= 0 ? queryOrFragmentIndex : currentUri.Length;
var lastSlashIndex = currentUri[..pathOnlyLength].LastIndexOf('/');

if (lastSlashIndex < 0)
{
// No slash found - this shouldn't happen for valid absolute URIs
// In this edge case, just append to the current URI
return string.Concat(_uri, relativeUri);
}

// Keep everything up to and including the last slash, then append the relative URI
var basePathLength = lastSlashIndex + 1;
return string.Concat(currentUri[..basePathLength], relativeUri.AsSpan());
Comment on lines +174 to +190
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

The implementation of PathRelative navigation doesn't handle several important relative path patterns that developers might reasonably expect to work:

  1. Absolute paths (e.g., "/about.html") - These start with / and should navigate to the root, but currently would be concatenated to the current directory path
  2. Parent directory navigation (e.g., "../sibling.html") - The ../ pattern to navigate up directories is not handled
  3. Current directory references (e.g., "./page.html") - The ./ prefix is not explicitly handled

Consider either:

  • Documenting these limitations explicitly in the XML comments for PathRelative property
  • Or implementing proper relative URI resolution using standard URI path resolution rules (which would handle ../ , /, and ./ correctly)

Standard URI path resolution is typically done using new Uri(baseUri, relativeUri) which automatically handles these cases. If you want to avoid allocating Uri objects for performance, you could implement the standard path resolution algorithm manually using spans.

Suggested change
var currentUri = _uri!.AsSpan();
// Find the last slash in the path portion (before any query or fragment)
var queryOrFragmentIndex = currentUri.IndexOfAny('?', '#');
var pathOnlyLength = queryOrFragmentIndex >= 0 ? queryOrFragmentIndex : currentUri.Length;
var lastSlashIndex = currentUri[..pathOnlyLength].LastIndexOf('/');
if (lastSlashIndex < 0)
{
// No slash found - this shouldn't happen for valid absolute URIs
// In this edge case, just append to the current URI
return string.Concat(_uri, relativeUri);
}
// Keep everything up to and including the last slash, then append the relative URI
var basePathLength = lastSlashIndex + 1;
return string.Concat(currentUri[..basePathLength], relativeUri.AsSpan());
// Use standard URI resolution to handle absolute paths, ../, ./, etc.
// _uri is the current absolute URI (including scheme, host, path, query, fragment)
var baseUri = new Uri(_uri!, UriKind.Absolute);
var resolvedUri = new Uri(baseUri, relativeUri);
return resolvedUri.ToString();

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Navigates to the specified URI.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/Components/Components/src/NavigationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ public readonly struct NavigationOptions
/// Gets or sets the state to append to the history entry.
/// </summary>
public string? HistoryEntryState { get; init; }

/// <summary>
/// If true, resolves relative URIs relative to the current path instead of the base URI.
/// If false (default), resolves relative URIs relative to the base URI.
/// </summary>
public bool PathRelative { get; init; }
}
2 changes: 2 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.get -> bool
Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.init -> void
117 changes: 116 additions & 1 deletion src/Components/Components/test/NavigationManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,107 @@ public void OnNotFoundSubscriptionIsTriggeredWhenNotFoundCalled()
// Assert
Assert.True(notFoundTriggered, "The OnNotFound event was not triggered as expected.");
}


[Fact]
public void NavigateTo_WithPathRelative_ResolvesRelativeToCurrentPath()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder1/folder2/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("sibling.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/folder1/folder2/sibling.html", testNavManager.Navigations[0].uri);
Assert.True(testNavManager.Navigations[0].options.PathRelative);
}

[Fact]
public void NavigateTo_WithPathRelative_HandlesQueryAndFragmentInCurrentUri()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder1/page.html?query=value#hash";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("other.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/folder1/other.html", testNavManager.Navigations[0].uri);
}

[Fact]
public void NavigateTo_WithPathRelativeFalse_DoesNotResolve()
{
var baseUri = "scheme://host/base/";
var currentUri = "scheme://host/base/folder1/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("relative.html", new NavigationOptions { PathRelative = false });

Assert.Single(testNavManager.Navigations);
// When PathRelative is false, the URI is passed directly to NavigateToCore without resolution
Assert.Equal("relative.html", testNavManager.Navigations[0].uri);
Assert.False(testNavManager.Navigations[0].options.PathRelative);
}

[Fact]
public void NavigateTo_WithPathRelative_AtRootLevel()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("other.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/other.html", testNavManager.Navigations[0].uri);
}

[Fact]
public void NavigateTo_WithPathRelative_NestedPaths()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/a/b/c/d/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("sibling.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/a/b/c/d/sibling.html", testNavManager.Navigations[0].uri);
}

[Fact]
public void NavigateTo_WithPathRelative_WithQueryStringPreservesPath()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/page.html?param=value";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("other.html?new=param", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/folder/other.html?new=param", testNavManager.Navigations[0].uri);
}

[Fact]
public void ResolveRelativeToCurrentPath_NoSlashFound_EdgeCase()
{
// This tests the defensive edge case where no slash is found in the URI
// We use reflection to set _uri to an invalid value (bypassing validation)
var baseUri = "scheme://host/";
var testNavManager = new TestNavigationManager(baseUri, "scheme://host/page.html");

// Use reflection to set _uri to an invalid value that has no slash
var uriField = typeof(NavigationManager).GetField("_uri", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
uriField.SetValue(testNavManager, "invaliduri");

// Call the internal method directly (accessible because of InternalsVisibleTo)
var result = testNavManager.ResolveRelativeToCurrentPath("page.html");

// When no slash is found, it concatenates to the current URI
Assert.Equal("invaliduripage.html", result);
}

Comment on lines +890 to +989
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

Test coverage is missing for important edge cases in path-relative navigation:

  1. Absolute paths: What happens when navigating with PathRelative = true to "/about.html"? Currently this would incorrectly concatenate to the current path.
  2. Parent directory navigation: What happens with "../parent.html" or "../../grandparent.html"?
  3. Current directory references: What happens with "./sibling.html"?
  4. Current URI ending in directory: What happens when current URI is "scheme://host/folder/" (ends with slash) instead of a file?
  5. Fragment-only navigation: What happens with "#section"?
  6. Query-only navigation: What happens with "?param=value"?

These test cases are critical to validate the behavior and ensure developers understand what patterns are supported by this feature.

Copilot uses AI. Check for mistakes.
Comment on lines +971 to +989
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

Using reflection to directly test the internal ResolveRelativeToCurrentPath method and manipulate internal state (_uri field) creates a brittle test that:

  1. Depends on internal implementation details that could change
  2. Tests an edge case ("invaliduri" with no slashes) that cannot occur in real usage since Initialize() validates that URIs are absolute
  3. Would break if the internal field name or accessibility changes

Consider instead testing this edge case behavior through the public NavigateTo API, or remove this test entirely since the "no slash found" case is defensive programming for an impossible state in production code.

Suggested change
[Fact]
public void ResolveRelativeToCurrentPath_NoSlashFound_EdgeCase()
{
// This tests the defensive edge case where no slash is found in the URI
// We use reflection to set _uri to an invalid value (bypassing validation)
var baseUri = "scheme://host/";
var testNavManager = new TestNavigationManager(baseUri, "scheme://host/page.html");
// Use reflection to set _uri to an invalid value that has no slash
var uriField = typeof(NavigationManager).GetField("_uri", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
uriField.SetValue(testNavManager, "invaliduri");
// Call the internal method directly (accessible because of InternalsVisibleTo)
var result = testNavManager.ResolveRelativeToCurrentPath("page.html");
// When no slash is found, it concatenates to the current URI
Assert.Equal("invaliduripage.html", result);
}

Copilot uses AI. Check for mistakes.
private class TestNavigationManager : NavigationManager
{
public TestNavigationManager()
Expand Down Expand Up @@ -916,6 +1016,21 @@ protected override void SetNavigationLockState(bool value)
}
}

private class TestNavigationManagerWithNavigationTracking : TestNavigationManager
{
public List<(string uri, NavigationOptions options)> Navigations { get; } = new();

public TestNavigationManagerWithNavigationTracking(string baseUri = null, string uri = null)
: base(baseUri, uri)
{
}

protected override void NavigateToCore(string uri, NavigationOptions options)
{
Navigations.Add((uri, options));
}
}

private class TestNavigationManagerWithLocationChangingExceptionTracking : TestNavigationManager
{
private readonly List<Exception> _exceptionsThrownFromLocationChangingHandlers = new();
Expand Down
1 change: 1 addition & 0 deletions src/Components/Web.JS/src/Services/NavigationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,5 @@ export interface NavigationOptions {
forceLoad: boolean;
replaceHistoryEntry: boolean;
historyEntryState?: string;
pathRelative?: boolean;
}
2 changes: 2 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#nullable enable
Microsoft.AspNetCore.Components.Routing.NavLink.PathRelative.get -> bool
Microsoft.AspNetCore.Components.Routing.NavLink.PathRelative.set -> void
Microsoft.AspNetCore.Components.Web.Media.FileDownload
Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.Web.Media.FileDownloadContext!>?
Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.set -> void
Expand Down
25 changes: 22 additions & 3 deletions src/Components/Web/src/Routing/NavLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class NavLink : ComponentBase, IDisposable

private bool _isActive;
private string? _hrefAbsolute;
private string? _hrefToRender;
private string? _class;

/// <summary>
Expand Down Expand Up @@ -52,6 +53,13 @@ public class NavLink : ComponentBase, IDisposable
[Parameter]
public NavLinkMatch Match { get; set; }

/// <summary>
/// Gets or sets whether the href should be resolved relative to the current path.
/// When true, the href is treated as relative to the current route path.
/// </summary>
[Parameter]
public bool PathRelative { get; set; }

[Inject] private NavigationManager NavigationManager { get; set; } = default!;

/// <inheritdoc />
Expand All @@ -71,7 +79,14 @@ protected override void OnParametersSet()
href = Convert.ToString(obj, CultureInfo.InvariantCulture);
}

// Resolve relative path if PathRelative is true
if (PathRelative && href != null)
{
href = NavigationManager.ResolveRelativeToCurrentPath(href);
}

_hrefAbsolute = href == null ? null : NavigationManager.ToAbsoluteUri(href).AbsoluteUri;
_hrefToRender = _hrefAbsolute;
_isActive = ShouldMatch(NavigationManager.Uri);

_class = (string?)null;
Expand Down Expand Up @@ -214,12 +229,16 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenElement(0, "a");

builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
if (_hrefToRender != null)
{
builder.AddAttribute(2, "href", _hrefToRender);
}
builder.AddAttribute(3, "class", CssClass);
if (_isActive)
{
builder.AddAttribute(3, "aria-current", "page");
builder.AddAttribute(4, "aria-current", "page");
}
builder.AddContent(4, ChildContent);
builder.AddContent(5, ChildContent);

builder.CloseElement();
}
Expand Down
Loading
Loading