diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs
index fecbfaca6c28..6bbdedd5a622 100644
--- a/src/Components/Components/src/NavigationManager.cs
+++ b/src/Components/Components/src/NavigationManager.cs
@@ -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());
+ }
+
///
/// Navigates to the specified URI.
///
diff --git a/src/Components/Components/src/NavigationOptions.cs b/src/Components/Components/src/NavigationOptions.cs
index 0e501b1c492a..38ba84185df5 100644
--- a/src/Components/Components/src/NavigationOptions.cs
+++ b/src/Components/Components/src/NavigationOptions.cs
@@ -23,4 +23,10 @@ public readonly struct NavigationOptions
/// Gets or sets the state to append to the history entry.
///
public string? HistoryEntryState { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public bool PathRelative { get; init; }
}
diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..503c903261f5 100644
--- a/src/Components/Components/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Components/src/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.get -> bool
+Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.init -> void
diff --git a/src/Components/Components/test/NavigationManagerTest.cs b/src/Components/Components/test/NavigationManagerTest.cs
index 3f6e680d0c8b..b8d7283fc107 100644
--- a/src/Components/Components/test/NavigationManagerTest.cs
+++ b/src/Components/Components/test/NavigationManagerTest.cs
@@ -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);
+ }
+
private class TestNavigationManager : NavigationManager
{
public TestNavigationManager()
@@ -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 _exceptionsThrownFromLocationChangingHandlers = new();
diff --git a/src/Components/Web.JS/src/Services/NavigationManager.ts b/src/Components/Web.JS/src/Services/NavigationManager.ts
index 20b0340973ed..c313ced34593 100644
--- a/src/Components/Web.JS/src/Services/NavigationManager.ts
+++ b/src/Components/Web.JS/src/Services/NavigationManager.ts
@@ -291,4 +291,5 @@ export interface NavigationOptions {
forceLoad: boolean;
replaceHistoryEntry: boolean;
historyEntryState?: string;
+ pathRelative?: boolean;
}
diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt
index 369f33715778..4520e5d41bbc 100644
--- a/src/Components/Web/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Web/src/PublicAPI.Unshipped.txt
@@ -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.FileDownload.ChildContent.set -> void
diff --git a/src/Components/Web/src/Routing/NavLink.cs b/src/Components/Web/src/Routing/NavLink.cs
index e3462e5a0f97..a631ab47e353 100644
--- a/src/Components/Web/src/Routing/NavLink.cs
+++ b/src/Components/Web/src/Routing/NavLink.cs
@@ -19,6 +19,7 @@ public class NavLink : ComponentBase, IDisposable
private bool _isActive;
private string? _hrefAbsolute;
+ private string? _hrefToRender;
private string? _class;
///
@@ -52,6 +53,13 @@ public class NavLink : ComponentBase, IDisposable
[Parameter]
public NavLinkMatch Match { get; set; }
+ ///
+ /// 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.
+ ///
+ [Parameter]
+ public bool PathRelative { get; set; }
+
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
///
@@ -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;
@@ -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();
}
diff --git a/src/Components/Web/test/Routing/NavLinkTest.cs b/src/Components/Web/test/Routing/NavLinkTest.cs
new file mode 100644
index 000000000000..078748b20faa
--- /dev/null
+++ b/src/Components/Web/test/Routing/NavLinkTest.cs
@@ -0,0 +1,207 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
+namespace Microsoft.AspNetCore.Components.Routing;
+
+public class NavLinkTest
+{
+ [Fact]
+ public async Task NavLink_WithPathRelative_ResolvesHrefRelativeToCurrentPath()
+ {
+ var testNavigationManager = new TestNavigationManager();
+ testNavigationManager.Initialize("https://example.com/", "https://example.com/sub-site/page-a");
+
+ var renderer = new TestRenderer();
+ var component = new NavLink();
+
+ var componentId = renderer.AssignRootComponentId(component);
+ SetNavigationManager(component, testNavigationManager);
+
+ var parameters = ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(NavLink.PathRelative)] = true,
+ [nameof(NavLink.AdditionalAttributes)] = new Dictionary
+ {
+ ["href"] = "details"
+ }
+ });
+
+ await renderer.RenderRootComponentAsync(componentId, parameters);
+
+ var batch = renderer.Batches.Single();
+ var hrefFrame = batch.ReferenceFrames.First(f => f.AttributeName == "href");
+ Assert.Equal("https://example.com/sub-site/details", hrefFrame.AttributeValue);
+ }
+
+ [Fact]
+ public async Task NavLink_WithPathRelative_HandlesNestedPaths()
+ {
+ var testNavigationManager = new TestNavigationManager();
+ testNavigationManager.Initialize("https://example.com/", "https://example.com/a/b/c/page");
+
+ var renderer = new TestRenderer();
+ var component = new NavLink();
+
+ var componentId = renderer.AssignRootComponentId(component);
+ SetNavigationManager(component, testNavigationManager);
+
+ var parameters = ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(NavLink.PathRelative)] = true,
+ [nameof(NavLink.AdditionalAttributes)] = new Dictionary
+ {
+ ["href"] = "sibling"
+ }
+ });
+
+ await renderer.RenderRootComponentAsync(componentId, parameters);
+
+ var batch = renderer.Batches.Single();
+ var hrefFrame = batch.ReferenceFrames.First(f => f.AttributeName == "href");
+ Assert.Equal("https://example.com/a/b/c/sibling", hrefFrame.AttributeValue);
+ }
+
+ [Fact]
+ public async Task NavLink_WithPathRelative_HandlesQueryAndFragment()
+ {
+ var testNavigationManager = new TestNavigationManager();
+ testNavigationManager.Initialize("https://example.com/", "https://example.com/folder/page?query=value#hash");
+
+ var renderer = new TestRenderer();
+ var component = new NavLink();
+
+ var componentId = renderer.AssignRootComponentId(component);
+ SetNavigationManager(component, testNavigationManager);
+
+ var parameters = ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(NavLink.PathRelative)] = true,
+ [nameof(NavLink.AdditionalAttributes)] = new Dictionary
+ {
+ ["href"] = "other"
+ }
+ });
+
+ await renderer.RenderRootComponentAsync(componentId, parameters);
+
+ var batch = renderer.Batches.Single();
+ var hrefFrame = batch.ReferenceFrames.First(f => f.AttributeName == "href");
+ Assert.Equal("https://example.com/folder/other", hrefFrame.AttributeValue);
+ }
+
+ [Fact]
+ public async Task NavLink_WithPathRelativeFalse_DoesNotResolve()
+ {
+ var testNavigationManager = new TestNavigationManager();
+ testNavigationManager.Initialize("https://example.com/", "https://example.com/folder/page");
+
+ var renderer = new TestRenderer();
+ var component = new NavLink();
+
+ var componentId = renderer.AssignRootComponentId(component);
+ SetNavigationManager(component, testNavigationManager);
+
+ var parameters = ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(NavLink.PathRelative)] = false,
+ [nameof(NavLink.AdditionalAttributes)] = new Dictionary
+ {
+ ["href"] = "relative"
+ }
+ });
+
+ await renderer.RenderRootComponentAsync(componentId, parameters);
+
+ var batch = renderer.Batches.Single();
+ var hrefFrame = batch.ReferenceFrames.First(f => f.AttributeName == "href");
+ // Should resolve relative to base URI, not current path
+ Assert.Equal("https://example.com/relative", hrefFrame.AttributeValue);
+ }
+
+ [Fact]
+ public async Task NavLink_WithPathRelative_AtRootLevel()
+ {
+ var testNavigationManager = new TestNavigationManager();
+ testNavigationManager.Initialize("https://example.com/", "https://example.com/page");
+
+ var renderer = new TestRenderer();
+ var component = new NavLink();
+
+ var componentId = renderer.AssignRootComponentId(component);
+ SetNavigationManager(component, testNavigationManager);
+
+ var parameters = ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(NavLink.PathRelative)] = true,
+ [nameof(NavLink.AdditionalAttributes)] = new Dictionary
+ {
+ ["href"] = "other"
+ }
+ });
+
+ await renderer.RenderRootComponentAsync(componentId, parameters);
+
+ var batch = renderer.Batches.Single();
+ var hrefFrame = batch.ReferenceFrames.First(f => f.AttributeName == "href");
+ Assert.Equal("https://example.com/other", hrefFrame.AttributeValue);
+ }
+
+ [Fact]
+ public async Task NavLink_WithPathRelative_PreservesActiveClassLogic()
+ {
+ var testNavigationManager = new TestNavigationManager();
+ testNavigationManager.Initialize("https://example.com/", "https://example.com/sub-site/details");
+
+ var renderer = new TestRenderer();
+ var component = new NavLink();
+
+ var componentId = renderer.AssignRootComponentId(component);
+ SetNavigationManager(component, testNavigationManager);
+
+ var parameters = ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(NavLink.PathRelative)] = true,
+ [nameof(NavLink.AdditionalAttributes)] = new Dictionary
+ {
+ ["href"] = "details"
+ }
+ });
+
+ // Initially on details page - link should be active
+ await renderer.RenderRootComponentAsync(componentId, parameters);
+ var batch = renderer.Batches.Single();
+ var classFrame = batch.ReferenceFrames.FirstOrDefault(f => f.AttributeName == "class");
+ Assert.Equal("active", classFrame.AttributeValue);
+ }
+
+ private void SetNavigationManager(NavLink component, NavigationManager navigationManager)
+ {
+ var navManagerProperty = typeof(NavLink).GetProperty("NavigationManager",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ navManagerProperty!.SetValue(component, navigationManager);
+ }
+
+ private class TestNavigationManager : NavigationManager
+ {
+ public TestNavigationManager()
+ {
+ }
+
+ public new void Initialize(string baseUri, string uri)
+ {
+ base.Initialize(baseUri, uri);
+ }
+
+ protected override void NavigateToCore(string uri, NavigationOptions options)
+ {
+ Uri = uri;
+ NotifyLocationChanged(false);
+ }
+ }
+}