From daa21ca73aa61320912c5b15f30f14bd3436d1e9 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 27 Aug 2025 11:55:10 +0200 Subject: [PATCH 1/5] Fix fetch request in enanced navigation --- .../src/Services/NavigationEnhancement.ts | 6 +++- .../Web.JS/src/Services/NavigationUtils.ts | 11 +++++++ .../EnhancedNavigationTest.cs | 29 +++++++++++++++++++ .../EnhancedNav/PageForScrollingToHash.razor | 5 ++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index 9dcd7540d866..d6cda4b30389 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. import { synchronizeDomContent } from '../Rendering/DomMerging/DomSync'; -import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, isSamePageWithHash, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage } from './NavigationUtils'; +import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, isSamePageWithHash, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage, isHashOnlyChange } from './NavigationUtils'; import { resetScrollAfterNextBatch, resetScrollIfNeeded } from '../Rendering/Renderer'; /* @@ -120,6 +120,10 @@ function onPopState(state: PopStateEvent) { return; } + if (isHashOnlyChange(currentContentUrl, location.href)){ + return; + } + // load the new page performEnhancedPageLoad(location.href, /* interceptedLink */ false); } diff --git a/src/Components/Web.JS/src/Services/NavigationUtils.ts b/src/Components/Web.JS/src/Services/NavigationUtils.ts index bc58636c39c6..c142d555c54d 100644 --- a/src/Components/Web.JS/src/Services/NavigationUtils.ts +++ b/src/Components/Web.JS/src/Services/NavigationUtils.ts @@ -52,6 +52,17 @@ export function isSamePageWithHash(absoluteHref: string): boolean { return url.hash !== '' && location.origin === url.origin && location.pathname === url.pathname && location.search === url.search; } +export function isHashOnlyChange(oldUrl: string, newUrl: string): boolean { + try { + const a = new URL(oldUrl); + const b = new URL(newUrl); + return a.origin === b.origin && a.pathname === b.pathname + && a.search === b.search && a.hash !== b.hash; + } catch { + return false; + } +} + export function isForSamePath(url1: string, url2: string) { // We are going to use the scheme, host, port and path to determine if the two URLs are compatible. // We do not account for the query string as we want to allow for the query string to change. diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs index 49ca00e06915..b0b453d6e1db 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.InternalTesting; using OpenQA.Selenium; using OpenQA.Selenium.BiDi.Communication; +using OpenQA.Selenium.DevTools; using OpenQA.Selenium.Support.Extensions; using TestServer; using Xunit.Abstractions; @@ -195,6 +196,34 @@ public void CanScrollToHashWithoutPerformingFullNavigation() .EndsWith("scroll-to-hash", StringComparison.Ordinal)); } + [Fact] + public void NonEnhancedNavCanScrollToHashWithoutFetchingPage() + { + Navigate($"{ServerPathBase}/nav/scroll-to-hash"); + Browser.Equal("Scroll to hash", () => Browser.Exists(By.TagName("h1")).Text); + + var javascript = (IJavaScriptExecutor)Browser; + javascript.ExecuteScript(@" + window.testFetchCalls = []; + const originalFetch = window.fetch; + window.fetch = function(...args) { + window.testFetchCalls.push(args[0]); + return originalFetch.apply(this, args); + };"); + + Browser.Exists(By.Id("scroll-anchor-enhance-nav-false")).Click(); + Browser.True(() => Browser.GetScrollY() > 500); + Browser.True(() => Browser + .Exists(By.Id("uri-on-page-load-enhance-nav-false")) + .GetDomAttribute("data-value") + .EndsWith("scroll-to-hash", StringComparison.Ordinal)); + + var fetchCalls = javascript.ExecuteScript("return window.testFetchCalls;") as IEnumerable; + var relevantCalls = fetchCalls?.Where(call => call.ToString().Contains("scroll-to-hash")) ?? Enumerable.Empty(); + + Assert.Empty(relevantCalls); + } + [Theory] [InlineData("server")] [InlineData("webassembly")] diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor index 102e18a84807..5255cfdd04ed 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor @@ -13,6 +13,11 @@

+

+ Scroll via anchor +

+

+
spacer
@if (showContent) From a1fb2212c2be7ac6ae7acad67de37e566bad5cfe Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 27 Aug 2025 13:04:06 +0200 Subject: [PATCH 2/5] Fix typo --- src/Components/Web.JS/src/Services/NavigationEnhancement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index d6cda4b30389..0252079fb856 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -120,7 +120,7 @@ function onPopState(state: PopStateEvent) { return; } - if (isHashOnlyChange(currentContentUrl, location.href)){ + if (isHashOnlyChange(currentContentUrl, location.href)) { return; } From 558cd83df15aab5e1048b02e0a4eb0a1241ef65f Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 27 Aug 2025 17:20:01 +0200 Subject: [PATCH 3/5] Feedback --- .../src/Services/NavigationEnhancement.ts | 6 +-- .../Web.JS/src/Services/NavigationManager.ts | 4 +- .../Web.JS/src/Services/NavigationUtils.ts | 9 +---- .../EnhancedNavigationTest.cs | 38 +++++++++++-------- .../EnhancedNav/PageForScrollingToHash.razor | 16 ++++++-- 5 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index 0252079fb856..d62c7b4e6c2e 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. import { synchronizeDomContent } from '../Rendering/DomMerging/DomSync'; -import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, isSamePageWithHash, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage, isHashOnlyChange } from './NavigationUtils'; +import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage, isSameUrlWithDifferentHash } from './NavigationUtils'; import { resetScrollAfterNextBatch, resetScrollIfNeeded } from '../Rendering/Renderer'; /* @@ -99,7 +99,7 @@ function onDocumentClick(event: MouseEvent) { handleClickForNavigationInterception(event, absoluteInternalHref => { const originalLocation = location.href; - const shouldScrollToHash = isSamePageWithHash(absoluteInternalHref); + const shouldScrollToHash = isSameUrlWithDifferentHash(originalLocation, absoluteInternalHref); history.pushState(null, /* ignored title */ '', absoluteInternalHref); if (shouldScrollToHash) { @@ -120,7 +120,7 @@ function onPopState(state: PopStateEvent) { return; } - if (isHashOnlyChange(currentContentUrl, location.href)) { + if (isSameUrlWithDifferentHash(currentContentUrl, location.href)) { return; } diff --git a/src/Components/Web.JS/src/Services/NavigationManager.ts b/src/Components/Web.JS/src/Services/NavigationManager.ts index b3352b399f55..a45ae454b004 100644 --- a/src/Components/Web.JS/src/Services/NavigationManager.ts +++ b/src/Components/Web.JS/src/Services/NavigationManager.ts @@ -4,7 +4,7 @@ import '@microsoft/dotnet-js-interop'; import { resetScrollAfterNextBatch } from '../Rendering/Renderer'; import { EventDelegator } from '../Rendering/Events/EventDelegator'; -import { attachEnhancedNavigationListener, getInteractiveRouterRendererId, handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isForSamePath, isSamePageWithHash, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, performScrollToElementOnTheSamePage, scrollToElement, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils'; +import { attachEnhancedNavigationListener, getInteractiveRouterRendererId, handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isForSamePath, isSameUrlWithDifferentHash, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, performScrollToElementOnTheSamePage, scrollToElement, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils'; import { WebRendererId } from '../Rendering/WebRendererId'; import { isRendererAttached } from '../Rendering/WebRendererInteropMethods'; @@ -150,7 +150,7 @@ function performExternalNavigation(uri: string, replace: boolean) { async function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean, state: string | undefined = undefined, skipLocationChangingCallback = false) { ignorePendingNavigation(); - if (isSamePageWithHash(absoluteInternalHref)) { + if (isSameUrlWithDifferentHash(location.href, absoluteInternalHref)) { saveToBrowserHistory(absoluteInternalHref, replace, state); performScrollToElementOnTheSamePage(absoluteInternalHref); return; diff --git a/src/Components/Web.JS/src/Services/NavigationUtils.ts b/src/Components/Web.JS/src/Services/NavigationUtils.ts index c142d555c54d..613db7791f9e 100644 --- a/src/Components/Web.JS/src/Services/NavigationUtils.ts +++ b/src/Components/Web.JS/src/Services/NavigationUtils.ts @@ -47,17 +47,12 @@ export function isWithinBaseUriSpace(href: string) { && (nextChar === '' || nextChar === '/' || nextChar === '?' || nextChar === '#'); } -export function isSamePageWithHash(absoluteHref: string): boolean { - const url = new URL(absoluteHref); - return url.hash !== '' && location.origin === url.origin && location.pathname === url.pathname && location.search === url.search; -} - -export function isHashOnlyChange(oldUrl: string, newUrl: string): boolean { +export function isSameUrlWithDifferentHash(oldUrl: string, newUrl: string): boolean { try { const a = new URL(oldUrl); const b = new URL(newUrl); return a.origin === b.origin && a.pathname === b.pathname - && a.search === b.search && a.hash !== b.hash; + && a.search === b.search && b.hash !== ''; } catch { return false; } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs index b0b453d6e1db..c11a30786c2e 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs @@ -197,31 +197,37 @@ public void CanScrollToHashWithoutPerformingFullNavigation() } [Fact] - public void NonEnhancedNavCanScrollToHashWithoutFetchingPage() + public void NonEnhancedNavCanScrollToHashWithoutFetchingPageAnchor() { Navigate($"{ServerPathBase}/nav/scroll-to-hash"); - Browser.Equal("Scroll to hash", () => Browser.Exists(By.TagName("h1")).Text); - - var javascript = (IJavaScriptExecutor)Browser; - javascript.ExecuteScript(@" - window.testFetchCalls = []; - const originalFetch = window.fetch; - window.fetch = function(...args) { - window.testFetchCalls.push(args[0]); - return originalFetch.apply(this, args); - };"); + var originalTextElem = Browser.Exists(By.CssSelector("#anchor #text")); + Browser.Equal("Text", () => originalTextElem.Text); - Browser.Exists(By.Id("scroll-anchor-enhance-nav-false")).Click(); + Browser.Exists(By.CssSelector("#anchor #scroll-anchor")).Click(); Browser.True(() => Browser.GetScrollY() > 500); Browser.True(() => Browser - .Exists(By.Id("uri-on-page-load-enhance-nav-false")) + .Exists(By.CssSelector("#anchor #uri-on-page-load")) .GetDomAttribute("data-value") .EndsWith("scroll-to-hash", StringComparison.Ordinal)); - var fetchCalls = javascript.ExecuteScript("return window.testFetchCalls;") as IEnumerable; - var relevantCalls = fetchCalls?.Where(call => call.ToString().Contains("scroll-to-hash")) ?? Enumerable.Empty(); + Browser.Equal("Text", () => originalTextElem.Text); + } + + [Fact] + public void NonEnhancedNavCanScrollToHashWithoutFetchingPageNavLink() + { + Navigate($"{ServerPathBase}/nav/scroll-to-hash"); + var originalTextElem = Browser.Exists(By.CssSelector("#navlink #text")); + Browser.Equal("Text", () => originalTextElem.Text); + + Browser.Exists(By.CssSelector("#navlink #scroll-anchor")).Click(); + Browser.True(() => Browser.GetScrollY() > 500); + Browser.True(() => Browser + .Exists(By.CssSelector("#navlink #uri-on-page-load")) + .GetDomAttribute("data-value") + .EndsWith("scroll-to-hash", StringComparison.Ordinal)); - Assert.Empty(relevantCalls); + Browser.Equal("Text", () => originalTextElem.Text); } [Theory] diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor index 5255cfdd04ed..22597ce37be3 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/PageForScrollingToHash.razor @@ -1,6 +1,7 @@ @page "/nav/scroll-to-hash" @attribute [StreamRendering] @inject NavigationManager NavigationManager +@using Microsoft.AspNetCore.Components.Forms Page for scrolling to hash @@ -13,10 +14,17 @@

-

- Scroll via anchor -

-

+
+ Scroll via anchor + +

Text

+
+ +
spacer
From c365e7d53d941d2874b2e2f83e723f6e01a4b501 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Fri, 29 Aug 2025 11:34:09 +0200 Subject: [PATCH 4/5] Fix --- src/Components/Web.JS/src/Services/NavigationEnhancement.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index d62c7b4e6c2e..aa0a3665a56f 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -120,7 +120,8 @@ function onPopState(state: PopStateEvent) { return; } - if (isSameUrlWithDifferentHash(currentContentUrl, location.href)) { + if (state.state == null && isSameUrlWithDifferentHash(currentContentUrl, location.href)) { + currentContentUrl = location.href; return; } From 84466b251176f9eb917a87aff6930248f94efa36 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Fri, 29 Aug 2025 12:25:11 +0200 Subject: [PATCH 5/5] Feedback --- .../Web.JS/src/Services/NavigationEnhancement.ts | 6 +++--- .../Web.JS/src/Services/NavigationManager.ts | 4 ++-- .../Web.JS/src/Services/NavigationUtils.ts | 14 +++++--------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index aa0a3665a56f..c39a00bd0337 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. import { synchronizeDomContent } from '../Rendering/DomMerging/DomSync'; -import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage, isSameUrlWithDifferentHash } from './NavigationUtils'; +import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage, isSamePageWithHash } from './NavigationUtils'; import { resetScrollAfterNextBatch, resetScrollIfNeeded } from '../Rendering/Renderer'; /* @@ -99,7 +99,7 @@ function onDocumentClick(event: MouseEvent) { handleClickForNavigationInterception(event, absoluteInternalHref => { const originalLocation = location.href; - const shouldScrollToHash = isSameUrlWithDifferentHash(originalLocation, absoluteInternalHref); + const shouldScrollToHash = isSamePageWithHash(originalLocation, absoluteInternalHref); history.pushState(null, /* ignored title */ '', absoluteInternalHref); if (shouldScrollToHash) { @@ -120,7 +120,7 @@ function onPopState(state: PopStateEvent) { return; } - if (state.state == null && isSameUrlWithDifferentHash(currentContentUrl, location.href)) { + if (state.state == null && isSamePageWithHash(currentContentUrl, location.href)) { currentContentUrl = location.href; return; } diff --git a/src/Components/Web.JS/src/Services/NavigationManager.ts b/src/Components/Web.JS/src/Services/NavigationManager.ts index a45ae454b004..8e2de809505a 100644 --- a/src/Components/Web.JS/src/Services/NavigationManager.ts +++ b/src/Components/Web.JS/src/Services/NavigationManager.ts @@ -4,7 +4,7 @@ import '@microsoft/dotnet-js-interop'; import { resetScrollAfterNextBatch } from '../Rendering/Renderer'; import { EventDelegator } from '../Rendering/Events/EventDelegator'; -import { attachEnhancedNavigationListener, getInteractiveRouterRendererId, handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isForSamePath, isSameUrlWithDifferentHash, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, performScrollToElementOnTheSamePage, scrollToElement, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils'; +import { attachEnhancedNavigationListener, getInteractiveRouterRendererId, handleClickForNavigationInterception, hasInteractiveRouter, hasProgrammaticEnhancedNavigationHandler, isForSamePath, isSamePageWithHash, isWithinBaseUriSpace, performProgrammaticEnhancedNavigation, performScrollToElementOnTheSamePage, scrollToElement, setHasInteractiveRouter, toAbsoluteUri } from './NavigationUtils'; import { WebRendererId } from '../Rendering/WebRendererId'; import { isRendererAttached } from '../Rendering/WebRendererInteropMethods'; @@ -150,7 +150,7 @@ function performExternalNavigation(uri: string, replace: boolean) { async function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean, state: string | undefined = undefined, skipLocationChangingCallback = false) { ignorePendingNavigation(); - if (isSameUrlWithDifferentHash(location.href, absoluteInternalHref)) { + if (isSamePageWithHash(location.href, absoluteInternalHref)) { saveToBrowserHistory(absoluteInternalHref, replace, state); performScrollToElementOnTheSamePage(absoluteInternalHref); return; diff --git a/src/Components/Web.JS/src/Services/NavigationUtils.ts b/src/Components/Web.JS/src/Services/NavigationUtils.ts index 613db7791f9e..9976eafc898c 100644 --- a/src/Components/Web.JS/src/Services/NavigationUtils.ts +++ b/src/Components/Web.JS/src/Services/NavigationUtils.ts @@ -47,15 +47,11 @@ export function isWithinBaseUriSpace(href: string) { && (nextChar === '' || nextChar === '/' || nextChar === '?' || nextChar === '#'); } -export function isSameUrlWithDifferentHash(oldUrl: string, newUrl: string): boolean { - try { - const a = new URL(oldUrl); - const b = new URL(newUrl); - return a.origin === b.origin && a.pathname === b.pathname - && a.search === b.search && b.hash !== ''; - } catch { - return false; - } +export function isSamePageWithHash(oldUrl: string, newUrl: string): boolean { + const a = new URL(oldUrl); + const b = new URL(newUrl); + return a.origin === b.origin && a.pathname === b.pathname + && a.search === b.search && b.hash !== ''; } export function isForSamePath(url1: string, url2: string) {