Skip to content

Commit d498fbc

Browse files
authored
Ignore query/fragment in ShouldMatch of NavLink by default but allow overriding ShouldMatch (#59903)
* NavLinkMatch.All matches queries and fragments with an option to override this behavior. * App context switch to disable the update.
1 parent 4bb5e98 commit d498fbc

File tree

7 files changed

+192
-17
lines changed

7 files changed

+192
-17
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! currentUriAbsolute) -> bool

src/Components/Web/src/Routing/NavLink.cs

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Diagnostics;
54
using System.Globalization;
65
using Microsoft.AspNetCore.Components.Rendering;
76

@@ -13,6 +12,9 @@ namespace Microsoft.AspNetCore.Components.Routing;
1312
/// </summary>
1413
public class NavLink : ComponentBase, IDisposable
1514
{
15+
private const string DisableMatchAllIgnoresLeftUriPartSwitchKey = "Microsoft.AspNetCore.Components.Routing.NavLink.DisableMatchAllIgnoresLeftUriPart";
16+
private static readonly bool _disableMatchAllIgnoresLeftUriPart = AppContext.TryGetSwitch(DisableMatchAllIgnoresLeftUriPartSwitchKey, out var switchValue) && switchValue;
17+
1618
private const string DefaultActiveClass = "active";
1719

1820
private bool _isActive;
@@ -106,14 +108,21 @@ private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
106108
}
107109
}
108110

109-
private bool ShouldMatch(string currentUriAbsolute)
111+
/// <summary>
112+
/// Determines whether the current URI should match the link.
113+
/// </summary>
114+
/// <param name="currentUriAbsolute">The absolute URI of the current location.</param>
115+
/// <returns>True if the link should be highlighted as active; otherwise, false.</returns>
116+
protected virtual bool ShouldMatch(string currentUriAbsolute)
110117
{
111118
if (_hrefAbsolute == null)
112119
{
113120
return false;
114121
}
115122

116-
if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute))
123+
var currentUriAbsoluteSpan = currentUriAbsolute.AsSpan();
124+
var hrefAbsoluteSpan = _hrefAbsolute.AsSpan();
125+
if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsoluteSpan, hrefAbsoluteSpan))
117126
{
118127
return true;
119128
}
@@ -124,19 +133,62 @@ private bool ShouldMatch(string currentUriAbsolute)
124133
return true;
125134
}
126135

127-
return false;
136+
if (_disableMatchAllIgnoresLeftUriPart || Match != NavLinkMatch.All)
137+
{
138+
return false;
139+
}
140+
141+
var uriWithoutQueryAndFragment = GetUriIgnoreQueryAndFragment(currentUriAbsoluteSpan);
142+
if (EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan))
143+
{
144+
return true;
145+
}
146+
hrefAbsoluteSpan = GetUriIgnoreQueryAndFragment(hrefAbsoluteSpan);
147+
return EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan);
128148
}
129149

130-
private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)
150+
private static ReadOnlySpan<char> GetUriIgnoreQueryAndFragment(ReadOnlySpan<char> uri)
131151
{
132-
Debug.Assert(_hrefAbsolute != null);
152+
if (uri.IsEmpty)
153+
{
154+
return ReadOnlySpan<char>.Empty;
155+
}
133156

134-
if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase))
157+
var queryStartPos = uri.IndexOf('?');
158+
var fragmentStartPos = uri.IndexOf('#');
159+
160+
if (queryStartPos < 0 && fragmentStartPos < 0)
161+
{
162+
return uri;
163+
}
164+
165+
int minPos;
166+
if (queryStartPos < 0)
167+
{
168+
minPos = fragmentStartPos;
169+
}
170+
else if (fragmentStartPos < 0)
171+
{
172+
minPos = queryStartPos;
173+
}
174+
else
175+
{
176+
minPos = Math.Min(queryStartPos, fragmentStartPos);
177+
}
178+
179+
return uri.Slice(0, minPos);
180+
}
181+
182+
private static readonly CaseInsensitiveCharComparer CaseInsensitiveComparer = new CaseInsensitiveCharComparer();
183+
184+
private static bool EqualsHrefExactlyOrIfTrailingSlashAdded(ReadOnlySpan<char> currentUriAbsolute, ReadOnlySpan<char> hrefAbsolute)
185+
{
186+
if (currentUriAbsolute.SequenceEqual(hrefAbsolute, CaseInsensitiveComparer))
135187
{
136188
return true;
137189
}
138190

139-
if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1)
191+
if (currentUriAbsolute.Length == hrefAbsolute.Length - 1)
140192
{
141193
// Special case: highlight links to http://host/path/ even if you're
142194
// at http://host/path (with no trailing slash)
@@ -146,8 +198,8 @@ private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)
146198
// which in turn is because it's common for servers to return the same page
147199
// for http://host/vdir as they do for host://host/vdir/ as it's no
148200
// good to display a blank page in that case.
149-
if (_hrefAbsolute[_hrefAbsolute.Length - 1] == '/'
150-
&& _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase))
201+
if (hrefAbsolute[hrefAbsolute.Length - 1] == '/' &&
202+
currentUriAbsolute.SequenceEqual(hrefAbsolute.Slice(0, hrefAbsolute.Length - 1), CaseInsensitiveComparer))
151203
{
152204
return true;
153205
}
@@ -199,7 +251,7 @@ private static bool IsStrictlyPrefixWithSeparator(string value, string prefix)
199251

200252
private static bool IsUnreservedCharacter(char c)
201253
{
202-
// Checks whether it is an unreserved character according to
254+
// Checks whether it is an unreserved character according to
203255
// https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
204256
// Those are characters that are allowed in a URI but do not have a reserved
205257
// purpose (e.g. they do not separate the components of the URI)
@@ -209,4 +261,17 @@ private static bool IsUnreservedCharacter(char c)
209261
c == '_' ||
210262
c == '~';
211263
}
264+
265+
private class CaseInsensitiveCharComparer : IEqualityComparer<char>
266+
{
267+
public bool Equals(char x, char y)
268+
{
269+
return char.ToLowerInvariant(x) == char.ToLowerInvariant(y);
270+
}
271+
272+
public int GetHashCode(char obj)
273+
{
274+
return char.ToLowerInvariant(obj).GetHashCode();
275+
}
276+
}
212277
}

src/Components/test/E2ETest/Tests/RoutingTest.cs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ public void CanFollowLinkToOtherPageWithQueryString()
299299
var app = Browser.MountTestComponent<TestRouter>();
300300
app.FindElement(By.LinkText("Other with query")).Click();
301301
Browser.Equal("This is another page.", () => app.FindElement(By.Id("test-info")).Text);
302-
AssertHighlightedLinks("Other", "Other with query");
302+
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)", "Other with query");
303303
}
304304

305305
[Fact]
@@ -310,7 +310,10 @@ public void CanFollowLinkToDefaultPageWithQueryString()
310310
var app = Browser.MountTestComponent<TestRouter>();
311311
app.FindElement(By.LinkText("Default with query")).Click();
312312
Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text);
313-
AssertHighlightedLinks("Default with query");
313+
AssertHighlightedLinks(
314+
"Default (matches all)",
315+
"Default with base-relative URL (matches all)",
316+
"Default with query");
314317
}
315318

316319
[Fact]
@@ -321,7 +324,11 @@ public void CanFollowLinkToDefaultPageWithQueryString_NoTrailingSlash()
321324
var app = Browser.MountTestComponent<TestRouter>();
322325
app.FindElement(By.LinkText("Default with query, no trailing slash")).Click();
323326
Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text);
324-
AssertHighlightedLinks("Default with query, no trailing slash");
327+
AssertHighlightedLinks(
328+
"Default (matches all)",
329+
"Default with base-relative URL (matches all)",
330+
"Default, no trailing slash (matches all)",
331+
"Default with query, no trailing slash");
325332
}
326333

327334
[Fact]
@@ -332,7 +339,7 @@ public void CanFollowLinkToOtherPageWithHash()
332339
var app = Browser.MountTestComponent<TestRouter>();
333340
app.FindElement(By.LinkText("Other with hash")).Click();
334341
Browser.Equal("This is another page.", () => app.FindElement(By.Id("test-info")).Text);
335-
AssertHighlightedLinks("Other", "Other with hash");
342+
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)", "Other with hash");
336343
}
337344

338345
[Fact]
@@ -343,7 +350,10 @@ public void CanFollowLinkToDefaultPageWithHash()
343350
var app = Browser.MountTestComponent<TestRouter>();
344351
app.FindElement(By.LinkText("Default with hash")).Click();
345352
Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text);
346-
AssertHighlightedLinks("Default with hash");
353+
AssertHighlightedLinks(
354+
"Default (matches all)",
355+
"Default with base-relative URL (matches all)",
356+
"Default with hash");
347357
}
348358

349359
[Fact]
@@ -354,7 +364,11 @@ public void CanFollowLinkToDefaultPageWithHash_NoTrailingSlash()
354364
var app = Browser.MountTestComponent<TestRouter>();
355365
app.FindElement(By.LinkText("Default with hash, no trailing slash")).Click();
356366
Browser.Equal("This is the default page.", () => app.FindElement(By.Id("test-info")).Text);
357-
AssertHighlightedLinks("Default with hash, no trailing slash");
367+
AssertHighlightedLinks(
368+
"Default (matches all)",
369+
"Default with base-relative URL (matches all)",
370+
"Default, no trailing slash (matches all)",
371+
"Default with hash, no trailing slash");
358372
}
359373

360374
[Fact]
@@ -383,6 +397,28 @@ public void CanFollowLinkDefinedInOpenShadowRoot()
383397
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
384398
}
385399

400+
[Fact]
401+
public void CanOverrideNavLinkToNotIgnoreFragment()
402+
{
403+
SetUrlViaPushState("/layout-overridden/for-hash");
404+
405+
var app = Browser.MountTestComponent<TestRouter>();
406+
app.FindElement(By.LinkText("Override layout with hash, no trailing slash")).Click();
407+
Browser.Equal("This is the page with overridden layout.", () => app.FindElement(By.Id("test-info")).Text);
408+
AssertHighlightedLinks("Override layout with hash, no trailing slash");
409+
}
410+
411+
[Fact]
412+
public void CanOverrideNavLinkToNotIgnoreQuery()
413+
{
414+
SetUrlViaPushState("/layout-overridden");
415+
416+
var app = Browser.MountTestComponent<TestRouter>();
417+
app.FindElement(By.LinkText("Override layout with query, no trailing slash")).Click();
418+
Browser.Equal("This is the page with overridden layout.", () => app.FindElement(By.Id("test-info")).Text);
419+
AssertHighlightedLinks("Override layout with query, no trailing slash");
420+
}
421+
386422
[Fact]
387423
public void CanGoBackFromNotAComponent()
388424
{
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@page "/layout-overridden"
2+
@page "/layout-overridden/for-hash"
3+
@layout RouterTestLayoutNavLinksOverridden
4+
<div id="test-info">This is the page with overridden layout.</div>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
@using Microsoft.AspNetCore.Components.Routing
2+
<style type="text/css">
3+
a.active {
4+
background-color: yellow;
5+
font-weight: bold;
6+
}
7+
</style>
8+
<ul>
9+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/" Match=NavLinkMatch.All>Override layout (matches all)</NavLinkNotIgnoreQueryOrFragmentString></li>
10+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden" Match=NavLinkMatch.All>Override layout, no trailing slash (matches all)</NavLinkNotIgnoreQueryOrFragmentString></li>
11+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/?abc=123">Override layout with query</NavLinkNotIgnoreQueryOrFragmentString></li>
12+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden?abc=123">Override layout with query, no trailing slash</NavLinkNotIgnoreQueryOrFragmentString></li>
13+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/#blah">Override layout with hash</NavLinkNotIgnoreQueryOrFragmentString></li>
14+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden#blah">Override layout with hash, no trailing slash</NavLinkNotIgnoreQueryOrFragmentString></li>
15+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/Default.html">Override layout with extension</NavLinkNotIgnoreQueryOrFragmentString></li>
16+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/layout-overridden/Other">Override Other</NavLinkNotIgnoreQueryOrFragmentString></li>
17+
<li><NavLinkNotIgnoreQueryOrFragmentString href="/subdir/Other" Match=NavLinkMatch.All>Override Other with base-relative URL (matches all)</NavLinkNotIgnoreQueryOrFragmentString></li>
18+
</ul>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Globalization;
5+
using Microsoft.AspNetCore.Components;
6+
using Microsoft.AspNetCore.Components.Routing;
7+
8+
public class NavLinkNotIgnoreQueryOrFragmentString : NavLink
9+
{
10+
string hrefAbsolute;
11+
NavigationManager _navigationManager;
12+
13+
public NavLinkNotIgnoreQueryOrFragmentString(NavigationManager navigationManager)
14+
{
15+
_navigationManager = navigationManager;
16+
}
17+
18+
protected override void OnInitialized()
19+
{
20+
string href = "";
21+
if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("href", out var obj))
22+
{
23+
href = Convert.ToString(obj, CultureInfo.InvariantCulture) ?? "";
24+
}
25+
hrefAbsolute = _navigationManager.ToAbsoluteUri(href).AbsoluteUri;
26+
base.OnInitialized();
27+
}
28+
protected override bool ShouldMatch(string currentUriAbsolute)
29+
{
30+
bool baseMatch = base.ShouldMatch(currentUriAbsolute);
31+
if (!baseMatch || string.IsNullOrEmpty(hrefAbsolute) || Match != NavLinkMatch.All)
32+
{
33+
return baseMatch;
34+
}
35+
36+
if (NormalizeUri(hrefAbsolute) == NormalizeUri(currentUriAbsolute))
37+
{
38+
return true;
39+
}
40+
return false;
41+
}
42+
43+
private static string NormalizeUri(string uri) =>
44+
uri.EndsWith('/') ? uri.TrimEnd('/') : uri;
45+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@using Microsoft.AspNetCore.Components
2+
@inherits LayoutComponentBase
3+
4+
@Body
5+
6+
<BasicTestApp.RouterTest.LinksOverridden />

0 commit comments

Comments
 (0)