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); + } + } +}