diff --git a/src/Components/Web.JS/src/Boot.Web.ts b/src/Components/Web.JS/src/Boot.Web.ts index df605ceebc52..9abcfee32287 100644 --- a/src/Components/Web.JS/src/Boot.Web.ts +++ b/src/Components/Web.JS/src/Boot.Web.ts @@ -15,6 +15,7 @@ import { shouldAutoStart } from './BootCommon'; import { Blazor } from './GlobalExports'; import { WebStartOptions } from './Platform/WebStartOptions'; import { attachStreamingRenderingListener } from './Rendering/StreamingRendering'; +import { resetScrollIfNeeded, ScrollResetSchedule } from './Rendering/Renderer'; import { NavigationEnhancementCallbacks, attachProgressivelyEnhancedNavigationListener } from './Services/NavigationEnhancement'; import { WebRootComponentManager } from './Services/WebRootComponentManager'; import { hasProgrammaticEnhancedNavigationHandler, performProgrammaticEnhancedNavigation } from './Services/NavigationUtils'; @@ -57,6 +58,7 @@ function boot(options?: Partial) : Promise { }, documentUpdated: () => { rootComponentManager.onDocumentUpdated(); + resetScrollIfNeeded(ScrollResetSchedule.AfterDocumentUpdate); jsEventRegistry.dispatchEvent('enhancedload', {}); }, enhancedNavigationCompleted() { diff --git a/src/Components/Web.JS/src/Rendering/Renderer.ts b/src/Components/Web.JS/src/Rendering/Renderer.ts index a5619de92702..cce062e89866 100644 --- a/src/Components/Web.JS/src/Rendering/Renderer.ts +++ b/src/Components/Web.JS/src/Rendering/Renderer.ts @@ -11,8 +11,15 @@ import { getAndRemovePendingRootComponentContainer } from './JSRootComponents'; interface BrowserRendererRegistry { [browserRendererId: number]: BrowserRenderer; } + +export enum ScrollResetSchedule { + None, + AfterBatch, // Reset scroll after interactive components finish rendering (interactive navigation) + AfterDocumentUpdate, // Reset scroll after enhanced navigation updates the DOM (enhanced navigation) +} + const browserRenderers: BrowserRendererRegistry = {}; -let shouldResetScrollAfterNextBatch = false; +let pendingScrollResetTiming: ScrollResetSchedule = ScrollResetSchedule.None; export function attachRootComponentToLogicalElement(browserRendererId: number, logicalElement: LogicalElement, componentId: number, appendContent: boolean): void { let browserRenderer = browserRenderers[browserRendererId]; @@ -88,19 +95,28 @@ export function renderBatch(browserRendererId: number, batch: RenderBatch): void browserRenderer.disposeEventHandler(eventHandlerId); } - resetScrollIfNeeded(); + resetScrollIfNeeded(ScrollResetSchedule.AfterBatch); } -export function resetScrollAfterNextBatch(): void { - shouldResetScrollAfterNextBatch = true; -} +export function scheduleScrollReset(timing: ScrollResetSchedule): void { + if (timing !== ScrollResetSchedule.AfterBatch) { + pendingScrollResetTiming = timing; + return; + } -export function resetScrollIfNeeded() { - if (shouldResetScrollAfterNextBatch) { - shouldResetScrollAfterNextBatch = false; + if (pendingScrollResetTiming !== ScrollResetSchedule.AfterDocumentUpdate) { + pendingScrollResetTiming = ScrollResetSchedule.AfterBatch; + } +} - // This assumes the scroller is on the window itself. There isn't a general way to know - // if some other element is playing the role of the primary scroll region. - window.scrollTo && window.scrollTo(0, 0); +export function resetScrollIfNeeded(triggerTiming: ScrollResetSchedule) { + if (pendingScrollResetTiming !== triggerTiming) { + return; } + + pendingScrollResetTiming = ScrollResetSchedule.None; + + // This assumes the scroller is on the window itself. There isn't a general way to know + // if some other element is playing the role of the primary scroll region. + window.scrollTo && window.scrollTo(0, 0); } diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index c39a00bd0337..9eb91a80e01d 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -3,7 +3,7 @@ import { synchronizeDomContent } from '../Rendering/DomMerging/DomSync'; import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage, isSamePageWithHash } from './NavigationUtils'; -import { resetScrollAfterNextBatch, resetScrollIfNeeded } from '../Rendering/Renderer'; +import { scheduleScrollReset, ScrollResetSchedule } from '../Rendering/Renderer'; /* In effect, we have two separate client-side navigation mechanisms: @@ -81,7 +81,7 @@ function performProgrammaticEnhancedNavigation(absoluteInternalHref: string, rep } if (!isForSamePath(absoluteInternalHref, originalLocation)) { - resetScrollAfterNextBatch(); + scheduleScrollReset(ScrollResetSchedule.AfterDocumentUpdate); } performEnhancedPageLoad(absoluteInternalHref, /* interceptedLink */ false); @@ -108,8 +108,7 @@ function onDocumentClick(event: MouseEvent) { let isSelfNavigation = isForSamePath(absoluteInternalHref, originalLocation); performEnhancedPageLoad(absoluteInternalHref, /* interceptedLink */ true); if (!isSelfNavigation) { - resetScrollAfterNextBatch(); - resetScrollIfNeeded(); + scheduleScrollReset(ScrollResetSchedule.AfterDocumentUpdate); } } }); diff --git a/src/Components/Web.JS/src/Services/NavigationManager.ts b/src/Components/Web.JS/src/Services/NavigationManager.ts index 8e2de809505a..20b0340973ed 100644 --- a/src/Components/Web.JS/src/Services/NavigationManager.ts +++ b/src/Components/Web.JS/src/Services/NavigationManager.ts @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. import '@microsoft/dotnet-js-interop'; -import { resetScrollAfterNextBatch } from '../Rendering/Renderer'; +import { scheduleScrollReset, ScrollResetSchedule } 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 { WebRendererId } from '../Rendering/WebRendererId'; @@ -170,7 +170,7 @@ async function performInternalNavigation(absoluteInternalHref: string, intercept // To avoid ugly flickering effects, we don't want to change the scroll position until // we render the new page. As a best approximation, wait until the next batch. if (!isForSamePath(absoluteInternalHref, location.href)) { - resetScrollAfterNextBatch(); + scheduleScrollReset(ScrollResetSchedule.AfterBatch); } saveToBrowserHistory(absoluteInternalHref, replace, state); diff --git a/src/Components/test/E2ETest/Infrastructure/ScrollOverrideScope.cs b/src/Components/test/E2ETest/Infrastructure/ScrollOverrideScope.cs new file mode 100644 index 000000000000..e6f84fabcf56 --- /dev/null +++ b/src/Components/test/E2ETest/Infrastructure/ScrollOverrideScope.cs @@ -0,0 +1,181 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using OpenQA.Selenium; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure; + +internal sealed class ScrollOverrideScope : IDisposable +{ + private readonly IJavaScriptExecutor _executor; + private readonly bool _isActive; + + public ScrollOverrideScope(IWebDriver browser, bool isActive) + { + _executor = (IJavaScriptExecutor)browser; + _isActive = isActive; + + if (!_isActive) + { + return; + } + + _executor.ExecuteScript(@" +(function() { + if (window.__enhancedNavScrollOverride) { + if (window.__clearEnhancedNavScrollLog) { + window.__clearEnhancedNavScrollLog(); + } + return; + } + + const original = window.scrollTo.bind(window); + const log = []; + + function resolvePage() { + const landing = document.getElementById('test-info-1'); + if (landing && landing.textContent === 'Scroll tests landing page') { + return 'landing'; + } + + const next = document.getElementById('test-info-2'); + if (next && next.textContent === 'Scroll tests next page') { + return 'next'; + } + + return 'other'; + } + + window.__enhancedNavScrollOverride = true; + window.__enhancedNavOriginalScrollTo = original; + window.__enhancedNavScrollLog = log; + window.__clearEnhancedNavScrollLog = () => { log.length = 0; }; + window.__drainEnhancedNavScrollLog = () => { + const copy = log.slice(); + log.length = 0; + return copy; + }; + + window.scrollTo = function(...args) { + log.push({ + page: resolvePage(), + url: location.href, + time: performance.now(), + args + }); + + return original(...args); + }; +})(); +"); + + ClearLog(); + } + + public void ClearLog() + { + if (!_isActive) + { + return; + } + + _executor.ExecuteScript("if (window.__clearEnhancedNavScrollLog) { window.__clearEnhancedNavScrollLog(); }"); + } + + public void AssertNoPrematureScroll(string expectedPage, string navigationDescription) + { + if (!_isActive) + { + return; + } + + var entries = DrainLog(); + if (entries.Length == 0) + { + return; + } + + var unexpectedEntries = entries + .Where(entry => !string.Equals(entry.Page, expectedPage, StringComparison.Ordinal)) + .ToArray(); + + if (unexpectedEntries.Length == 0) + { + return; + } + + var details = string.Join( + ", ", + unexpectedEntries.Select(entry => $"page={entry.Page ?? "null"} url={entry.Url} time={entry.Time:F2}")); + + throw new XunitException($"Detected a scroll reset while the DOM still displayed '{unexpectedEntries[0].Page ?? "unknown"}' during {navigationDescription}. Entries: {details}"); + } + + private ScrollInvocation[] DrainLog() + { + if (!_isActive) + { + return Array.Empty(); + } + + var result = _executor.ExecuteScript("return window.__drainEnhancedNavScrollLog ? window.__drainEnhancedNavScrollLog() : [];"); + if (result is not IReadOnlyList entries || entries.Count == 0) + { + return Array.Empty(); + } + + var resolved = new ScrollInvocation[entries.Count]; + for (var i = 0; i < entries.Count; i++) + { + if (entries[i] is IReadOnlyDictionary dict) + { + dict.TryGetValue("page", out var pageValue); + dict.TryGetValue("url", out var urlValue); + dict.TryGetValue("time", out var timeValue); + + resolved[i] = new ScrollInvocation( + pageValue as string, + urlValue as string, + timeValue is null ? 0D : Convert.ToDouble(timeValue, CultureInfo.InvariantCulture)); + continue; + } + + resolved[i] = new ScrollInvocation(null, null, 0D); + } + + return resolved; + } + + public void Dispose() + { + if (!_isActive) + { + return; + } + + _executor.ExecuteScript(@" +(function() { + if (!window.__enhancedNavScrollOverride) { + return; + } + + if (window.__enhancedNavOriginalScrollTo) { + window.scrollTo = window.__enhancedNavOriginalScrollTo; + delete window.__enhancedNavOriginalScrollTo; + } + + delete window.__enhancedNavScrollOverride; + delete window.__enhancedNavScrollLog; + delete window.__clearEnhancedNavScrollLog; + delete window.__drainEnhancedNavScrollLog; +})(); +"); + } + + private readonly record struct ScrollInvocation(string Page, string Url, double Time); +} diff --git a/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs b/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs index fd24da459497..c6221085a514 100644 --- a/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs +++ b/src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/WebDriverExtensions.cs @@ -1,8 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Threading; using OpenQA.Selenium; using OpenQA.Selenium.Support.UI; +using Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; namespace Microsoft.AspNetCore.Components.E2ETest; @@ -64,4 +67,78 @@ public static long GetElementPositionWithRetry(this IWebDriver browser, string e throw new Exception($"Failed to get position for element '{elementId}' after {retryCount} retries. Debug log: {log}"); } + + internal static ScrollObservation BeginScrollObservation(this IWebDriver browser, IWebElement element, Func domMutationPredicate) + { + ArgumentNullException.ThrowIfNull(browser); + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(domMutationPredicate); + + var initialScrollPosition = browser.GetScrollY(); + return new ScrollObservation(element, initialScrollPosition, domMutationPredicate); + } + + internal static ScrollObservationResult WaitForStaleDomOrScrollChange(this IWebDriver browser, ScrollObservation observation, TimeSpan? timeout = null, TimeSpan? pollingInterval = null) + { + ArgumentNullException.ThrowIfNull(browser); + + var wait = new DefaultWait(browser) + { + Timeout = timeout ?? TimeSpan.FromSeconds(10), + PollingInterval = pollingInterval ?? TimeSpan.FromMilliseconds(50), + }; + wait.IgnoreExceptionTypes(typeof(InvalidOperationException)); + + ScrollObservationOutcome? detectedOutcome = null; + wait.Until(driver => + { + if (observation.DomMutationPredicate(driver)) + { + detectedOutcome = ScrollObservationOutcome.DomUpdated; + return true; + } + + if (observation.Element.IsStale()) + { + detectedOutcome = ScrollObservationOutcome.DomUpdated; + return true; + } + + if (browser.GetScrollY() != observation.InitialScrollPosition) + { + detectedOutcome = ScrollObservationOutcome.ScrollChanged; + return true; + } + + return false; + }); + + var outcome = detectedOutcome ?? ScrollObservationOutcome.DomUpdated; + + var finalScrollPosition = browser.GetScrollY(); + return new ScrollObservationResult(outcome, observation.InitialScrollPosition, finalScrollPosition); + } + + internal static bool IsStale(this IWebElement element) + { + try + { + _ = element.Enabled; + return false; + } + catch (StaleElementReferenceException) + { + return true; + } + } +} + +internal readonly record struct ScrollObservation(IWebElement Element, long InitialScrollPosition, Func DomMutationPredicate); + +internal readonly record struct ScrollObservationResult(ScrollObservationOutcome Outcome, long InitialScrollPosition, long FinalScrollPosition); + +internal enum ScrollObservationOutcome +{ + ScrollChanged, + DomUpdated, } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs index c11a30786c2e..5b28c9d299ac 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/EnhancedNavigationTest.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Globalization; using System.Threading.Tasks; using Components.TestServer.RazorComponents; using Microsoft.AspNetCore.Components.E2ETest; @@ -247,22 +249,22 @@ public void CanPerformProgrammaticEnhancedNavigation(string renderMode) Browser.Exists(By.TagName("nav")).FindElement(By.LinkText($"Interactive component navigation ({renderMode})")).Click(); Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text); - Browser.False(() => IsElementStale(elementForStalenessCheck)); + Browser.False(() => elementForStalenessCheck.IsStale()); Browser.Exists(By.Id("navigate-to-another-page")).Click(); Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text); Assert.EndsWith("/nav", Browser.Url); - Browser.False(() => IsElementStale(elementForStalenessCheck)); + Browser.False(() => elementForStalenessCheck.IsStale()); // Ensure that the history stack was correctly updated Browser.Navigate().Back(); Browser.Equal("Page with interactive components that navigate", () => Browser.Exists(By.TagName("h1")).Text); - Browser.False(() => IsElementStale(elementForStalenessCheck)); + Browser.False(() => elementForStalenessCheck.IsStale()); Browser.Navigate().Back(); Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text); Assert.EndsWith("/nav", Browser.Url); - Browser.False(() => IsElementStale(elementForStalenessCheck)); + Browser.False(() => elementForStalenessCheck.IsStale()); } [Theory] @@ -289,7 +291,7 @@ public void CanPerformProgrammaticEnhancedRefresh(string renderMode, string refr Browser.Exists(By.Id(refreshButtonId)).Click(); Browser.True(() => { - if (IsElementStale(renderIdElement) || !int.TryParse(renderIdElement.Text, out var newRenderId)) + if (renderIdElement.IsStale() || !int.TryParse(renderIdElement.Text, out var newRenderId)) { return false; } @@ -323,7 +325,7 @@ public void NavigateToCanFallBackOnFullPageReload(string renderMode) Assert.NotEqual(-1, initialRenderId); Browser.Exists(By.Id("reload-with-navigate-to")).Click(); - Browser.True(() => IsElementStale(initialRenderIdElement)); + Browser.True(() => initialRenderIdElement.IsStale()); var finalRenderIdElement = Browser.Exists(By.Id("render-id")); var finalRenderId = -1; @@ -363,8 +365,8 @@ public void RefreshCanFallBackOnFullPageReload(string renderMode) Browser.True(() => int.TryParse(initialRenderIdElement.Text, out initialRenderId)); Assert.NotEqual(-1, initialRenderId); - Browser.Exists(By.Id("refresh-with-refresh")).Click(); - Browser.True(() => IsElementStale(initialRenderIdElement)); + Browser.Exists(By.Id("refresh-with-refresh")).Click(); + Browser.True(() => initialRenderIdElement.IsStale()); var finalRenderIdElement = Browser.Exists(By.Id("render-id")); var finalRenderId = -1; @@ -397,8 +399,8 @@ public void RefreshWithForceReloadDoesFullPageReload(string renderMode) Browser.True(() => int.TryParse(initialRenderIdElement.Text, out initialRenderId)); Assert.NotEqual(-1, initialRenderId); - Browser.Exists(By.Id("reload-with-refresh")).Click(); - Browser.True(() => IsElementStale(initialRenderIdElement)); + Browser.Exists(By.Id("reload-with-refresh")).Click(); + Browser.True(() => initialRenderIdElement.IsStale()); var finalRenderIdElement = Browser.Exists(By.Id("render-id")); var finalRenderId = -1; @@ -735,37 +737,67 @@ public void EnhancedNavigationScrollBehavesSameAsBrowserOnNavigation(bool enable // "landing" page: scroll maximally down and go to "next" page - we should land at the top of that page AssertWeAreOnLandingPage(); - // staleness check is used to assert enhanced navigation is enabled/disabled, as requested - var elementForStalenessCheckOnNextPage = Browser.Exists(By.TagName("html")); - - var button1Id = $"do{buttonKeyword}-navigation"; - var button1Pos = Browser.GetElementPositionWithRetry(button1Id); - Browser.SetScrollY(button1Pos); - Browser.Exists(By.Id(button1Id)).Click(); - - // "next" page: check if we landed at 0, then navigate to "landing" - AssertWeAreOnNextPage(); - WaitStreamingRendersFullPage(enableStreaming); - string fragmentId = "some-content"; - Browser.WaitForElementToBeVisible(By.Id(fragmentId)); - AssertEnhancedNavigation(useEnhancedNavigation, elementForStalenessCheckOnNextPage); - Assert.Equal(0, Browser.GetScrollY()); - var elementForStalenessCheckOnLandingPage = Browser.Exists(By.TagName("html")); - var fragmentScrollPosition = Browser.GetElementPositionWithRetry(fragmentId); - Browser.Exists(By.Id(button1Id)).Click(); + var scrollOverride = new ScrollOverrideScope(Browser, useEnhancedNavigation); - // "landing" page: navigate to a fragment on another page - we should land at the beginning of the fragment - AssertWeAreOnLandingPage(); - WaitStreamingRendersFullPage(enableStreaming); - AssertEnhancedNavigation(useEnhancedNavigation, elementForStalenessCheckOnLandingPage); - - var button2Id = $"do{buttonKeyword}-navigation-with-fragment"; - Browser.Exists(By.Id(button2Id)).Click(); - AssertWeAreOnNextPage(); - WaitStreamingRendersFullPage(enableStreaming); - AssertEnhancedNavigation(useEnhancedNavigation, elementForStalenessCheckOnNextPage); - var expectedFragmentScrollPosition = fragmentScrollPosition; - Assert.Equal(expectedFragmentScrollPosition, Browser.GetScrollY()); + try + { + // Staleness check is used to assert enhanced navigation is enabled/disabled, as requested + var elementForStalenessCheckOnNextPage = Browser.Exists(By.TagName("html")); + + var button1Id = $"do{buttonKeyword}-navigation"; + var button1Pos = Browser.GetElementPositionWithRetry(button1Id); + Browser.SetScrollY(button1Pos); + scrollOverride.ClearLog(); + var firstNavigationObservation = BeginEnhancedNavigationObservationIfEnhancedNavigation( + useEnhancedNavigation, + elementForStalenessCheckOnNextPage, + ElementWithTextAppears(By.Id("test-info-2"), "Scroll tests next page")); + Browser.Exists(By.Id(button1Id)).Click(); + + // "next" page: check if we landed at 0, then navigate to "landing" + AssertWeAreOnNextPage(); + WaitStreamingRendersFullPage(enableStreaming); + const string fragmentId = "some-content"; + Browser.WaitForElementToBeVisible(By.Id(fragmentId)); + AssertEnhancedNavigation(useEnhancedNavigation, elementForStalenessCheckOnNextPage); + AssertNoPrematureScrollBeforeDomSwapIfEnhancedNavigation(firstNavigationObservation, "landing -> next navigation"); + scrollOverride.AssertNoPrematureScroll("next", "landing -> next navigation"); + Assert.Equal(0, Browser.GetScrollY()); + var elementForStalenessCheckOnLandingPage = Browser.Exists(By.TagName("html")); + var fragmentScrollPosition = Browser.GetElementPositionWithRetry(fragmentId); + var secondNavigationObservation = BeginEnhancedNavigationObservationIfEnhancedNavigation( + useEnhancedNavigation, + elementForStalenessCheckOnLandingPage, + ElementWithTextAppears(By.Id("test-info-1"), "Scroll tests landing page")); + scrollOverride.ClearLog(); + Browser.Exists(By.Id(button1Id)).Click(); + + // "landing" page: navigate to a fragment on another page - we should land at the beginning of the fragment + AssertWeAreOnLandingPage(); + WaitStreamingRendersFullPage(enableStreaming); + AssertEnhancedNavigation(useEnhancedNavigation, elementForStalenessCheckOnLandingPage); + AssertNoPrematureScrollBeforeDomSwapIfEnhancedNavigation(secondNavigationObservation, "next -> landing navigation"); + scrollOverride.AssertNoPrematureScroll("landing", "next -> landing navigation"); + + var button2Id = $"do{buttonKeyword}-navigation-with-fragment"; + var thirdNavigationObservation = BeginEnhancedNavigationObservationIfEnhancedNavigation( + useEnhancedNavigation, + elementForStalenessCheckOnNextPage, + ElementWithTextAppears(By.Id("test-info-2"), "Scroll tests next page")); + scrollOverride.ClearLog(); + Browser.Exists(By.Id(button2Id)).Click(); + AssertWeAreOnNextPage(); + WaitStreamingRendersFullPage(enableStreaming); + AssertEnhancedNavigation(useEnhancedNavigation, elementForStalenessCheckOnNextPage); + AssertNoPrematureScrollBeforeDomSwapIfEnhancedNavigation(thirdNavigationObservation, "landing -> next (fragment) navigation"); + scrollOverride.AssertNoPrematureScroll("next", "landing -> next (fragment) navigation"); + var expectedFragmentScrollPosition = fragmentScrollPosition; + Assert.Equal(expectedFragmentScrollPosition, Browser.GetScrollY()); + } + finally + { + scrollOverride.Dispose(); + } } [Theory] @@ -874,7 +906,7 @@ private void AssertEnhancedNavigation(bool useEnhancedNavigation, IWebElement el { try { - enhancedNavigationDetected = !IsElementStale(elementForStalenessCheck); + enhancedNavigationDetected = !elementForStalenessCheck.IsStale(); Assert.Equal(useEnhancedNavigation, enhancedNavigationDetected); return; } @@ -920,16 +952,41 @@ private void WaitStreamingRendersFullPage(bool enableStreaming) private void AssertEnhancedUpdateCountEquals(long count) => Browser.Equal(count, () => ((IJavaScriptExecutor)Browser).ExecuteScript("return window.enhancedPageUpdateCount;")); - private static bool IsElementStale(IWebElement element) + private ScrollObservation? BeginEnhancedNavigationObservationIfEnhancedNavigation(bool useEnhancedNavigation, IWebElement elementForStalenessCheck, Func domMutationPredicate) => + useEnhancedNavigation ? Browser.BeginScrollObservation(elementForStalenessCheck, domMutationPredicate) : null; + + private void AssertNoPrematureScrollBeforeDomSwapIfEnhancedNavigation(ScrollObservation? observation, string navigationDescription) { + if (observation is not ScrollObservation context) + { + return; + } + + ScrollObservationResult result; try { - _ = element.Enabled; - return false; + result = Browser.WaitForStaleDomOrScrollChange(context); } - catch (StaleElementReferenceException) + catch (WebDriverTimeoutException ex) { - return true; + throw new XunitException($"Timed out while waiting for the DOM to update or the scroll position to change during {navigationDescription}.", ex); + } + + if (result.Outcome == ScrollObservationOutcome.ScrollChanged) + { + throw new XunitException($"Detected a scroll reset before the DOM update completed during {navigationDescription}. Scroll moved from {result.InitialScrollPosition} to {result.FinalScrollPosition} before the page rendered new content."); } } + + private static Func ElementWithTextAppears(By selector, string expectedText) => driver => + { + var elements = driver.FindElements(selector); + if (elements.Count == 0) + { + return false; + } + + // Ensure we actually observed the new content, not just the presence of the element. + return string.Equals(elements[0].Text, expectedText, StringComparison.Ordinal); + }; } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs index ad72e5b66b68..270db100927d 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Text; using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.E2ETesting; @@ -1634,7 +1635,7 @@ private void DispatchToFormCore(DispatchToForm dispatch) if (!dispatch.FormIsEnhanced) { // Verify the same form element is *not* still in the page - Browser.True(() => IsElementStale(form)); + Browser.True(() => form.IsStale()); } else if (!dispatch.SuppressEnhancedNavigation) { @@ -1686,19 +1687,6 @@ private void GoTo(string relativePath) Navigate($"{ServerPathBase}/{relativePath}"); } - private static bool IsElementStale(IWebElement element) - { - try - { - _ = element.Enabled; - return false; - } - catch (StaleElementReferenceException) - { - return true; - } - } - private struct TempFile { public string Name { get; }