diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 6ce14ee0952d..8e2d84f2f11b 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -362,6 +362,10 @@ private void CalcualteItemDistribution( _ => MaxItemCount }; + // Count the OverscanCount as used capacity, so we don't end up in a situation where + // the user has set a very low MaxItemCount and we end up in an infinite loading loop. + maxItemCount += OverscanCount * 2; + itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / _itemSize) - OverscanCount); visibleItemCapacity = (int)Math.Ceiling(containerSize / _itemSize) + 2 * OverscanCount; unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount); diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 565dc0190fcd..4fa21ef3fe11 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -291,14 +291,14 @@ public void CanLimitMaxItemsRendered(bool useAppContext) // we only render 10 items due to the MaxItemCount setting var scrollArea = Browser.Exists(By.Id("virtualize-scroll-area")); var getItems = () => scrollArea.FindElements(By.ClassName("my-item")); - Browser.Equal(10, () => getItems().Count); + Browser.Equal(16, () => getItems().Count); Browser.Equal("Id: 0; Name: Thing 0", () => getItems().First().Text); // Scrolling still works and loads new data, though there's no guarantee about // exactly how many items will show up at any one time Browser.ExecuteJavaScript("document.getElementById('virtualize-scroll-area').scrollTop = 300;"); Browser.NotEqual("Id: 0; Name: Thing 0", () => getItems().First().Text); - Browser.True(() => getItems().Count > 3 && getItems().Count <= 10); + Browser.True(() => getItems().Count > 3 && getItems().Count <= 16); } [Fact] @@ -573,6 +573,101 @@ public void EmptyContentRendered_Async() int GetPlaceholderCount() => Browser.FindElements(By.Id("async-placeholder")).Count; } + [Fact] + public void CanElevateEffectiveMaxItemCount_WhenOverscanExceedsMax() + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("virtualize-large-overscan")); + // Ensure we have an initial contiguous batch and the elevated effective max has kicked in (>= OverscanCount) + var indices = GetVisibleItemIndices(); + Browser.True(() => indices.Count >= 200); + + // Give focus so PageDown works + container.Click(); + + var js = (IJavaScriptExecutor)Browser; + var lastMaxIndex = -1; + var lastScrollTop = -1L; + + // Check if we've reached (or effectively reached) the bottom + var scrollHeight = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var clientHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + while (scrollTop + clientHeight < scrollHeight) + { + // Validate contiguity on the current page + Browser.True(() => IsCurrentViewContiguous(indices)); + + // Track progress in indices + var currentMax = indices.Max(); + Assert.True(currentMax >= lastMaxIndex, $"Unexpected backward movement: previous max {lastMaxIndex}, current max {currentMax}."); + lastMaxIndex = currentMax; + + // Send PageDown + container.SendKeys(Keys.PageDown); + + // Wait for scrollTop to change (progress) to avoid infinite loop + var prevScrollTop = scrollTop; + Browser.True(() => + { + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + if (st > prevScrollTop) + { + lastScrollTop = st; + return true; + } + return false; + }); + scrollHeight = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + clientHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + } + + // Final contiguous assertion at bottom + Browser.True(() => IsCurrentViewContiguous()); + + // Helper: check visible items contiguous with no holes + bool IsCurrentViewContiguous(List existingIndices = null) + { + var indices = existingIndices ?? GetVisibleItemIndices(); + if (indices.Count == 0) + { + return false; + } + + if (indices[^1] - indices[0] != indices.Count - 1) + { + return false; + } + for (var i = 1; i < indices.Count; i++) + { + if (indices[i] - indices[i - 1] != 1) + { + return false; + } + } + return true; + } + + List GetVisibleItemIndices() + { + var elements = container.FindElements(By.CssSelector(".large-overscan-item")); + var list = new List(elements.Count); + foreach (var el in elements) + { + var text = el.Text; + if (text.StartsWith("Item ", StringComparison.Ordinal)) + { + if (int.TryParse(text.AsSpan(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + { + list.Add(value); + } + } + } + return list; + } + } + private string[] GetPeopleNames(IWebElement container) { var peopleElements = container.FindElements(By.CssSelector(".person span")); diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index a3bc250f0634..d7df87adbeed 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -119,6 +119,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationLargeOverscan.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationLargeOverscan.razor new file mode 100644 index 000000000000..3beb29cf87c2 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationLargeOverscan.razor @@ -0,0 +1,12 @@ +@* Test component to validate behavior when OverscanCount greatly exceeds MaxItemCount. *@ +@using Microsoft.AspNetCore.Components.Web.Virtualization + +
+ +
Item @context
+
+
+ +@code { + private IList _items = Enumerable.Range(0, 5000).ToList(); +}