Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ignore query/fragment in ShouldMatch of NavLink by default but allow overriding ShouldMatch #59903

Merged
merged 11 commits into from
Feb 10, 2025
1 change: 1 addition & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! currentUriAbsolute) -> bool
87 changes: 76 additions & 11 deletions src/Components/Web/src/Routing/NavLink.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -13,6 +12,9 @@ namespace Microsoft.AspNetCore.Components.Routing;
/// </summary>
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;
Expand Down Expand Up @@ -106,14 +108,21 @@ private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
}
}

private bool ShouldMatch(string currentUriAbsolute)
/// <summary>
/// Determines whether the current URI should match the link.
/// </summary>
/// <param name="currentUriAbsolute">The absolute URI of the current location.</param>
/// <returns>True if the link should be highlighted as active; otherwise, false.</returns>
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;
}
Expand All @@ -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<char> GetUriIgnoreQueryAndFragment(ReadOnlySpan<char> uri)
{
Debug.Assert(_hrefAbsolute != null);
if (uri.IsEmpty)
{
return ReadOnlySpan<char>.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<char> currentUriAbsolute, ReadOnlySpan<char> 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)
Expand All @@ -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;
}
Expand Down Expand Up @@ -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)
Expand All @@ -209,4 +261,17 @@ private static bool IsUnreservedCharacter(char c)
c == '_' ||
c == '~';
}

private class CaseInsensitiveCharComparer : IEqualityComparer<char>
{
public bool Equals(char x, char y)
{
return char.ToLowerInvariant(x) == char.ToLowerInvariant(y);
}

public int GetHashCode(char obj)
{
return char.ToLowerInvariant(obj).GetHashCode();
}
}
}
48 changes: 42 additions & 6 deletions src/Components/test/E2ETest/Tests/RoutingTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ public void CanFollowLinkToOtherPageWithQueryString()
var app = Browser.MountTestComponent<TestRouter>();
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]
Expand All @@ -310,7 +310,10 @@ public void CanFollowLinkToDefaultPageWithQueryString()
var app = Browser.MountTestComponent<TestRouter>();
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]
Expand All @@ -321,7 +324,11 @@ public void CanFollowLinkToDefaultPageWithQueryString_NoTrailingSlash()
var app = Browser.MountTestComponent<TestRouter>();
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]
Expand All @@ -332,7 +339,7 @@ public void CanFollowLinkToOtherPageWithHash()
var app = Browser.MountTestComponent<TestRouter>();
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]
Expand All @@ -343,7 +350,10 @@ public void CanFollowLinkToDefaultPageWithHash()
var app = Browser.MountTestComponent<TestRouter>();
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]
Expand All @@ -354,7 +364,11 @@ public void CanFollowLinkToDefaultPageWithHash_NoTrailingSlash()
var app = Browser.MountTestComponent<TestRouter>();
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]
Expand Down Expand Up @@ -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<TestRouter>();
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<TestRouter>();
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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@page "/layout-overridden"
@page "/layout-overridden/for-hash"
@layout RouterTestLayoutNavLinksOverridden
<div id="test-info">This is the page with overridden layout.</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@using Microsoft.AspNetCore.Components.Routing
<style type="text/css">
a.active {
background-color: yellow;
font-weight: bold;
}
</style>
<ul>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/" Match=NavLinkMatch.All>Override layout (matches all)</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden" Match=NavLinkMatch.All>Override layout, no trailing slash (matches all)</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/?abc=123">Override layout with query</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden?abc=123">Override layout with query, no trailing slash</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/#blah">Override layout with hash</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden#blah">Override layout with hash, no trailing slash</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/Default.html">Override layout with extension</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/Other">Override Other</NavLinkNotIgnoreQueryOrFragmentString></li>
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/Other" Match=NavLinkMatch.All>Override Other with base-relative URL (matches all)</NavLinkNotIgnoreQueryOrFragmentString></li>
</ul>
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@using Microsoft.AspNetCore.Components
@inherits LayoutComponentBase

@Body

<BasicTestApp.RouterTest.LinksOverridden />
Loading