diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..fa9194d9adf3 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! currentUriAbsolute) -> bool \ No newline at end of file diff --git a/src/Components/Web/src/Routing/NavLink.cs b/src/Components/Web/src/Routing/NavLink.cs index 043019404559..ddc863268591 100644 --- a/src/Components/Web/src/Routing/NavLink.cs +++ b/src/Components/Web/src/Routing/NavLink.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; using System.Globalization; using Microsoft.AspNetCore.Components.Rendering; @@ -13,6 +12,9 @@ namespace Microsoft.AspNetCore.Components.Routing; /// public class NavLink : ComponentBase, IDisposable { + private const string DisableMatchAllIgnoresLeftUriPartSwitchKey = "Microsoft.AspNetCore.Components.Routing.NavLink.DisableMatchAllIgnoresLeftUriPart"; + private static readonly bool _disableMatchAllIgnoresLeftUriPart = AppContext.TryGetSwitch(DisableMatchAllIgnoresLeftUriPartSwitchKey, out var switchValue) && switchValue; + private const string DefaultActiveClass = "active"; private bool _isActive; @@ -106,14 +108,21 @@ private void OnLocationChanged(object? sender, LocationChangedEventArgs args) } } - private bool ShouldMatch(string currentUriAbsolute) + /// + /// Determines whether the current URI should match the link. + /// + /// The absolute URI of the current location. + /// True if the link should be highlighted as active; otherwise, false. + protected virtual bool ShouldMatch(string currentUriAbsolute) { if (_hrefAbsolute == null) { return false; } - if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute)) + var currentUriAbsoluteSpan = currentUriAbsolute.AsSpan(); + var hrefAbsoluteSpan = _hrefAbsolute.AsSpan(); + if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsoluteSpan, hrefAbsoluteSpan)) { return true; } @@ -124,19 +133,62 @@ private bool ShouldMatch(string currentUriAbsolute) return true; } - return false; + if (_disableMatchAllIgnoresLeftUriPart || Match != NavLinkMatch.All) + { + return false; + } + + var uriWithoutQueryAndFragment = GetUriIgnoreQueryAndFragment(currentUriAbsoluteSpan); + if (EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan)) + { + return true; + } + hrefAbsoluteSpan = GetUriIgnoreQueryAndFragment(hrefAbsoluteSpan); + return EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan); } - private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute) + private static ReadOnlySpan GetUriIgnoreQueryAndFragment(ReadOnlySpan uri) { - Debug.Assert(_hrefAbsolute != null); + if (uri.IsEmpty) + { + return ReadOnlySpan.Empty; + } - if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase)) + var queryStartPos = uri.IndexOf('?'); + var fragmentStartPos = uri.IndexOf('#'); + + if (queryStartPos < 0 && fragmentStartPos < 0) + { + return uri; + } + + int minPos; + if (queryStartPos < 0) + { + minPos = fragmentStartPos; + } + else if (fragmentStartPos < 0) + { + minPos = queryStartPos; + } + else + { + minPos = Math.Min(queryStartPos, fragmentStartPos); + } + + return uri.Slice(0, minPos); + } + + private static readonly CaseInsensitiveCharComparer CaseInsensitiveComparer = new CaseInsensitiveCharComparer(); + + private static bool EqualsHrefExactlyOrIfTrailingSlashAdded(ReadOnlySpan currentUriAbsolute, ReadOnlySpan hrefAbsolute) + { + if (currentUriAbsolute.SequenceEqual(hrefAbsolute, CaseInsensitiveComparer)) { return true; } - if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1) + if (currentUriAbsolute.Length == hrefAbsolute.Length - 1) { // Special case: highlight links to http://host/path/ even if you're // at http://host/path (with no trailing slash) @@ -146,8 +198,8 @@ private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute) // which in turn is because it's common for servers to return the same page // for http://host/vdir as they do for host://host/vdir/ as it's no // good to display a blank page in that case. - if (_hrefAbsolute[_hrefAbsolute.Length - 1] == '/' - && _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase)) + if (hrefAbsolute[hrefAbsolute.Length - 1] == '/' && + currentUriAbsolute.SequenceEqual(hrefAbsolute.Slice(0, hrefAbsolute.Length - 1), CaseInsensitiveComparer)) { return true; } @@ -199,7 +251,7 @@ private static bool IsStrictlyPrefixWithSeparator(string value, string prefix) private static bool IsUnreservedCharacter(char c) { - // Checks whether it is an unreserved character according to + // Checks whether it is an unreserved character according to // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 // Those are characters that are allowed in a URI but do not have a reserved // purpose (e.g. they do not separate the components of the URI) @@ -209,4 +261,17 @@ private static bool IsUnreservedCharacter(char c) c == '_' || c == '~'; } + + private class CaseInsensitiveCharComparer : IEqualityComparer + { + public bool Equals(char x, char y) + { + return char.ToLowerInvariant(x) == char.ToLowerInvariant(y); + } + + public int GetHashCode(char obj) + { + return char.ToLowerInvariant(obj).GetHashCode(); + } + } } diff --git a/src/Components/test/E2ETest/Tests/RoutingTest.cs b/src/Components/test/E2ETest/Tests/RoutingTest.cs index 6c430d24395c..06ba239d6e66 100644 --- a/src/Components/test/E2ETest/Tests/RoutingTest.cs +++ b/src/Components/test/E2ETest/Tests/RoutingTest.cs @@ -299,7 +299,7 @@ public void CanFollowLinkToOtherPageWithQueryString() var app = Browser.MountTestComponent(); app.FindElement(By.LinkText("Other with query")).Click(); Browser.Equal("This is another page.", () => app.FindElement(By.Id("test-info")).Text); - AssertHighlightedLinks("Other", "Other with query"); + AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)", "Other with query"); } [Fact] @@ -310,7 +310,10 @@ public void CanFollowLinkToDefaultPageWithQueryString() var app = Browser.MountTestComponent(); app.FindElement(By.LinkText("Default with query")).Click(); Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text); - AssertHighlightedLinks("Default with query"); + AssertHighlightedLinks( + "Default (matches all)", + "Default with base-relative URL (matches all)", + "Default with query"); } [Fact] @@ -321,7 +324,11 @@ public void CanFollowLinkToDefaultPageWithQueryString_NoTrailingSlash() var app = Browser.MountTestComponent(); app.FindElement(By.LinkText("Default with query, no trailing slash")).Click(); Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text); - AssertHighlightedLinks("Default with query, no trailing slash"); + AssertHighlightedLinks( + "Default (matches all)", + "Default with base-relative URL (matches all)", + "Default, no trailing slash (matches all)", + "Default with query, no trailing slash"); } [Fact] @@ -332,7 +339,7 @@ public void CanFollowLinkToOtherPageWithHash() var app = Browser.MountTestComponent(); app.FindElement(By.LinkText("Other with hash")).Click(); Browser.Equal("This is another page.", () => app.FindElement(By.Id("test-info")).Text); - AssertHighlightedLinks("Other", "Other with hash"); + AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)", "Other with hash"); } [Fact] @@ -343,7 +350,10 @@ public void CanFollowLinkToDefaultPageWithHash() var app = Browser.MountTestComponent(); app.FindElement(By.LinkText("Default with hash")).Click(); Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text); - AssertHighlightedLinks("Default with hash"); + AssertHighlightedLinks( + "Default (matches all)", + "Default with base-relative URL (matches all)", + "Default with hash"); } [Fact] @@ -354,7 +364,11 @@ public void CanFollowLinkToDefaultPageWithHash_NoTrailingSlash() var app = Browser.MountTestComponent(); app.FindElement(By.LinkText("Default with hash, no trailing slash")).Click(); Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text); - AssertHighlightedLinks("Default with hash, no trailing slash"); + AssertHighlightedLinks( + "Default (matches all)", + "Default with base-relative URL (matches all)", + "Default, no trailing slash (matches all)", + "Default with hash, no trailing slash"); } [Fact] @@ -383,6 +397,28 @@ public void CanFollowLinkDefinedInOpenShadowRoot() AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)"); } + [Fact] + public void CanOverrideNavLinkToNotIgnoreFragment() + { + SetUrlViaPushState("/layout-overridden/for-hash"); + + var app = Browser.MountTestComponent(); + app.FindElement(By.LinkText("Override layout with hash, no trailing slash")).Click(); + Browser.Equal("This is the page with overridden layout.", () => app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Override layout with hash, no trailing slash"); + } + + [Fact] + public void CanOverrideNavLinkToNotIgnoreQuery() + { + SetUrlViaPushState("/layout-overridden"); + + var app = Browser.MountTestComponent(); + app.FindElement(By.LinkText("Override layout with query, no trailing slash")).Click(); + Browser.Equal("This is the page with overridden layout.", () => app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Override layout with query, no trailing slash"); + } + [Fact] public void CanGoBackFromNotAComponent() { diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/LayoutOverridden.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/LayoutOverridden.razor new file mode 100644 index 000000000000..47d9c16d1c7a --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/LayoutOverridden.razor @@ -0,0 +1,4 @@ +@page "/layout-overridden" +@page "/layout-overridden/for-hash" +@layout RouterTestLayoutNavLinksOverridden +
This is the page with overridden layout.
\ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/LinksOverridden.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/LinksOverridden.razor new file mode 100644 index 000000000000..447d90c8b9f3 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/LinksOverridden.razor @@ -0,0 +1,18 @@ +@using Microsoft.AspNetCore.Components.Routing + +
    +
  • Override layout (matches all)
  • +
  • Override layout, no trailing slash (matches all)
  • +
  • Override layout with query
  • +
  • Override layout with query, no trailing slash
  • +
  • Override layout with hash
  • +
  • Override layout with hash, no trailing slash
  • +
  • Override layout with extension
  • +
  • Override Other
  • +
  • Override Other with base-relative URL (matches all)
  • +
\ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/NavLinkNotIgnoreQueryOrFragmentString.cs b/src/Components/test/testassets/BasicTestApp/RouterTest/NavLinkNotIgnoreQueryOrFragmentString.cs new file mode 100644 index 000000000000..e384e24b23e7 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/NavLinkNotIgnoreQueryOrFragmentString.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; + +public class NavLinkNotIgnoreQueryOrFragmentString : NavLink +{ + string hrefAbsolute; + NavigationManager _navigationManager; + + public NavLinkNotIgnoreQueryOrFragmentString(NavigationManager navigationManager) + { + _navigationManager = navigationManager; + } + + protected override void OnInitialized() + { + string href = ""; + if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("href", out var obj)) + { + href = Convert.ToString(obj, CultureInfo.InvariantCulture) ?? ""; + } + hrefAbsolute = _navigationManager.ToAbsoluteUri(href).AbsoluteUri; + base.OnInitialized(); + } + protected override bool ShouldMatch(string currentUriAbsolute) + { + bool baseMatch = base.ShouldMatch(currentUriAbsolute); + if (!baseMatch || string.IsNullOrEmpty(hrefAbsolute) || Match != NavLinkMatch.All) + { + return baseMatch; + } + + if (NormalizeUri(hrefAbsolute) == NormalizeUri(currentUriAbsolute)) + { + return true; + } + return false; + } + + private static string NormalizeUri(string uri) => + uri.EndsWith('/') ? uri.TrimEnd('/') : uri; +} diff --git a/src/Components/test/testassets/BasicTestApp/RouterTestLayoutNavLinksOverridden.razor b/src/Components/test/testassets/BasicTestApp/RouterTestLayoutNavLinksOverridden.razor new file mode 100644 index 000000000000..ff0c57ec65a9 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTestLayoutNavLinksOverridden.razor @@ -0,0 +1,6 @@ +@using Microsoft.AspNetCore.Components +@inherits LayoutComponentBase + +@Body + + \ No newline at end of file