diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 8135bcf0e4ac..24da2f68d909 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -392,7 +392,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } // End mode: pin new items into view if we're at the bottom now, or were and are still following. - if ((anchorModeIs.end || bottomTracking.following) && (bottomTracking.wasAtBottomLastRender || bottomTracking.reached)) { + if (bottomTracking.following + || (anchorModeIs.end && (bottomTracking.wasAtBottomLastRender || bottomTracking.reached))) { + flushPendingStyleMutations(); scrollElement.scrollTop = scrollElement.scrollHeight; scrollActivity.ignoreNextScroll(); // Start convergence only when there are more items to load (spacerAfter > 0). @@ -716,7 +718,12 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement, startConvergenceObserving, isFollowingBottom: () => bottomTracking.following, - setAnchorMode: (mode: number) => { anchorMode = mode; bottomTracking.following = (mode & 2) !== 0; bottomTracking.reached = isViewportAtBottom(); }, + setAnchorMode: (mode: number) => { + anchorMode = mode; + const atBottom = isViewportAtBottom(); + bottomTracking.following = (mode & 2) !== 0 && atBottom; + bottomTracking.reached = atBottom; + }, restoreAnchor: restoreAnchorForShift, alignToItem: alignToItemAt, beginProgrammaticScroll: beginProgrammaticScroll, diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 3ec61edda2ed..77293ffecb25 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.InternalTesting; using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Support.Extensions; using OpenQA.Selenium.Support.UI; @@ -5458,6 +5459,85 @@ private void MountAnchorModeForScrollToItem(bool useProvider, bool variableHeigh private void SetManualInitialIndex(int index) => SetNumberInputAndWaitForBind("manual-initial-index", index); + [Fact] + public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() + { + var emulateServerLatency = _serverFixture.ExecutionMode == ExecutionMode.Server; + var chromeDriver = (ChromeDriver)Browser; + if (emulateServerLatency) + { + SetNetworkConditions(chromeDriver, latency: 400, throughput: 50_000); + Navigate(ServerPathBase); + } + + try + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + Browser.Exists(By.Id("unload-list")).Click(); + Browser.Exists(By.Id("list-not-loaded")); + Browser.Exists(By.Id("toggle-provider")).Click(); + Browser.Contains("Switched to ItemsProvider", () => Browser.Exists(By.Id("status")).Text); + new SelectElement(Browser.Exists(By.Id("anchor-mode-select"))).SelectByValue("2"); + Browser.Equal("2", () => Browser.Exists(By.Id("current-mode")).Text); + Browser.Exists(By.Id("toggle-provider-gate")).Click(); + Browser.Contains("Provider gate: On", () => Browser.Exists(By.Id("status")).Text); + + Browser.Exists(By.Id("reload-with-initial-index")).Click(); + Browser.True(() => GetProviderCallIndex(js) == 1); + Browser.Contains("p1-enter", () => GetProviderEvents(js)); + + Browser.Exists(By.Id("release-provider-gate")).Click(); + Browser.Contains("p1-return", () => GetProviderEvents(js)); + Browser.True(() => GetMaximumScrollTop(js, container) > 0); + + Browser.True( + () => IsScrolledToBottom(js, container), + TimeSpan.FromSeconds(10), + $"Expected the initial provider result to pin to the bottom, but scrollTop was " + + $"{GetScrollTop(js, container)} of {GetMaximumScrollTop(js, container)}."); + + Browser.True(() => GetProviderCallIndex(js) == 2); + Browser.Exists(By.Id("release-provider-gate")).Click(); + Browser.Contains("p2-return", () => GetProviderEvents(js)); + Browser.True( + () => GetBottomRenderedIndex(js) == 999 && IsScrolledToBottom(js, container), + TimeSpan.FromSeconds(10), + $"Expected item 999 at the pinned tail, but the bottom rendered item was " + + $"{GetBottomRenderedIndex(js)} and scrollTop was {GetScrollTop(js, container)}."); + } + finally + { + if (emulateServerLatency) + { + SetNetworkConditions(chromeDriver, latency: 0, throughput: -1); + chromeDriver.ExecuteCdpCommand("Network.disable", new Dictionary()); + } + } + } + + private static void SetNetworkConditions(ChromeDriver chromeDriver, int latency, int throughput) + { + chromeDriver.ExecuteCdpCommand("Network.enable", new Dictionary()); + chromeDriver.ExecuteCdpCommand("Network.emulateNetworkConditions", new Dictionary + { + ["offline"] = false, + ["latency"] = latency, + ["downloadThroughput"] = throughput, + ["uploadThroughput"] = throughput, + }); + } + + private bool IsScrolledToBottom(IJavaScriptExecutor js, IWebElement container) + => Math.Abs(GetScrollTop(js, container) - GetMaximumScrollTop(js, container)) < 2; + + private static long GetMaximumScrollTop(IJavaScriptExecutor js, IWebElement container) + => Convert.ToInt64(js.ExecuteScript( + "return arguments[0].scrollHeight - arguments[0].clientHeight;", container), CultureInfo.InvariantCulture); + // Types into and polls the sibling {id}-bound span until the bound model commits (needed on Server where @bind round-trips over SignalR). private void SetNumberInputAndWaitForBind(string elementId, int value) {