Skip to content

Commit

Permalink
Ignore query/fragment in ShouldMatch of NavLink by default but al…
Browse files Browse the repository at this point in the history
…low overriding `ShouldMatch` (#59903)

* NavLinkMatch.All matches queries and fragments with an option to override this behavior.

* App context switch to disable the update.
  • Loading branch information
ilonatommy authored Feb 10, 2025
1 parent 4bb5e98 commit d498fbc
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 17 deletions.
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 />

0 comments on commit d498fbc

Please sign in to comment.