diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 1ffebda1ca38..1e729de7404f 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -308,6 +308,14 @@ private void CalcualteItemDistribution( private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity) { + // If the itemcount just changed to a lower number, and we're already scrolled past the end of the new + // reduced set of items, clamp the scroll position to the new maximum + if (itemsBefore + visibleItemCapacity > _itemCount) + { + itemsBefore = Math.Max(0, _itemCount - visibleItemCapacity); + } + + // If anything about the offset changed, re-render if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity) { _itemsBefore = itemsBefore; diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index e8dd8e3f8a79..7addac76d7f2 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Testing; using OpenQA.Selenium; using OpenQA.Selenium.Support.Extensions; +using OpenQA.Selenium.Support.UI; using Xunit; using Xunit.Abstractions; @@ -366,10 +367,69 @@ public void CanRefreshItemsProviderResultsInPlace() name => Assert.Equal("Person 3", name)); } + [Fact] + public void CanExpandDataSetAndRetainScrollPosition() + { + Browser.MountTestComponent(); + var dataSetLengthSelector = new SelectElement(Browser.Exists(By.Id("large-dataset-length"))); + var dataSetLengthLastRendered = () => int.Parse(Browser.FindElement(By.Id("large-dataset-length-lastrendered")).Text, CultureInfo.InvariantCulture); + var container = Browser.Exists(By.Id("removing-many")); + + // Scroll to the end of a medium list + dataSetLengthSelector.SelectByText("1000"); + Browser.Equal(1000, dataSetLengthLastRendered); + Browser.True(() => + { + ScrollToEnd(Browser, container); + return GetPeopleNames(container).Contains("Person 1000"); + }); + + // Expand the data set + dataSetLengthSelector.SelectByText("100000"); + Browser.Equal(100000, dataSetLengthLastRendered); + + // See that the old data is still visible, because the scroll position is preserved as a pixel count, + // not a scroll percentage + Browser.True(() => GetPeopleNames(container).Contains("Person 1000")); + } + + [Fact] + public void CanHandleDataSetShrinkingWithExistingOffsetAlreadyBeyondNewListEnd() + { + // Represents https://github.com/dotnet/aspnetcore/issues/37245 + Browser.MountTestComponent(); + var dataSetLengthSelector = new SelectElement(Browser.Exists(By.Id("large-dataset-length"))); + var dataSetLengthLastRendered = () => int.Parse(Browser.FindElement(By.Id("large-dataset-length-lastrendered")).Text, CultureInfo.InvariantCulture); + var container = Browser.Exists(By.Id("removing-many")); + + // Scroll to the end of a very long list + dataSetLengthSelector.SelectByText("100000"); + Browser.Equal(100000, dataSetLengthLastRendered); + Browser.True(() => + { + ScrollToEnd(Browser, container); + return GetPeopleNames(container).Contains("Person 100000"); + }); + + // Now make the dataset much shorter + // We should automatically have the scroll position reduced to the new maximum + // Because the new data set is *so much* shorter than the previous one, if bug #37245 were still here, + // this would take over 30 minutes so the test would fail + dataSetLengthSelector.SelectByText("25"); + Browser.Equal(25, dataSetLengthLastRendered); + Browser.True(() => GetPeopleNames(container).Contains("Person 25")); + } + private string[] GetPeopleNames(IWebElement container) { var peopleElements = container.FindElements(By.CssSelector(".person span")); return peopleElements.Select(element => element.Text).ToArray(); } + + private static void ScrollToEnd(IWebDriver browser, IWebElement elem) + { + var js = (IJavaScriptExecutor)browser; + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", elem); + } } } diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor index 0c409fe2e40a..e30c3cc44d22 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor @@ -38,11 +38,34 @@ +

Large data set with size changes

+

+ Data set length: + + Last rendered: + @largeDataSetLengthLastRendered +

+
+ +
+ @context.Name +
+
+
+ @code { Virtualize asyncVirtualize; + Virtualize removeManyVirtualize; List fixedPeople = Enumerable.Range(1, 3).Select(GeneratePerson).ToList(); int numPeopleInItemsProvider = 3; + int largeDataSetLength = 25; + int? largeDataSetLengthLastRendered; + void AddPersonToFixedList() { // When using Items (not ItemsProvider), the Virtualize component re-queries @@ -69,6 +92,27 @@ numPeopleInItemsProvider); } + async Task SetLargeDataSetLengthAsync(int length) + { + largeDataSetLength = length; + await removeManyVirtualize.RefreshDataAsync(); + } + + async ValueTask> GetLargeDataSetAsync(ItemsProviderRequest request) + { + await Task.Delay(500); + largeDataSetLengthLastRendered = largeDataSetLength; + + // Behave like a .Skip(startIndex).Take(count), so that if you ask for data beyond the end of the + // set, you get back nothing + var lastIndexExcl = Math.Min(request.StartIndex + request.Count, largeDataSetLength); + var effectiveCount = lastIndexExcl - request.StartIndex; + var resultItems = effectiveCount <= 0 + ? Enumerable.Empty() + : Enumerable.Range(1 + request.StartIndex, effectiveCount).Select(GeneratePerson).ToList(); + return new ItemsProviderResult(resultItems, largeDataSetLength); + } + class Person { public string Name { get; set; }