diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 5c174688d13c..551aa355b90b 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -3,5 +3,7 @@ Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(string! identifier, string? argsJson, Microsoft.JSInterop.JSCallResultType resultType, long targetInstanceId) -> string! Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.get -> bool Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.set -> void +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount.get -> int +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount.set -> void override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.RendererInfo.get -> Microsoft.AspNetCore.Components.RendererInfo! override Microsoft.AspNetCore.Components.Routing.FocusOnNavigate.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 61f22da4a73a..da5de296d8c6 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -25,6 +25,14 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private int _visibleItemCapacity; + // If the client reports a viewport so large that it could show more than MaxItemCount items, + // we keep track of the "unused" capacity, which is the amount of blank space we want to leave + // at the bottom of the viewport (as a number of items). If we didn't leave this blank space, + // then the bottom spacer would always stay visible and the client would request more items in an + // infinite (but asynchronous) loop, as it would believe there are more items to render and + // enough space to render them into. + private int _unusedItemCapacity; + private int _itemCount; private int _loadedItemsStartIndex; @@ -118,6 +126,17 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I [Parameter] public string SpacerElement { get; set; } = "div"; + /// + /// Gets or sets the maximum number of items that will be rendered, even if the client reports + /// that its viewport is large enough to show more. The default value is 100. + /// + /// This should only be used as a safeguard against excessive memory usage or large data loads. + /// Do not set this to a smaller number than you expect to fit on a realistic-sized window, because + /// that will leave a blank gap below and the user may not be able to see the rest of the content. + /// + [Parameter] + public int MaxItemCount { get; set; } = 100; + /// /// Instructs the component to re-request data from its . /// This is useful if external data may have changed. There is no need to call this @@ -264,18 +283,23 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) var itemsAfter = Math.Max(0, _itemCount - _visibleItemCapacity - _itemsBefore); builder.OpenElement(7, SpacerElement); - builder.AddAttribute(8, "style", GetSpacerStyle(itemsAfter)); + builder.AddAttribute(8, "style", GetSpacerStyle(itemsAfter, _unusedItemCapacity)); builder.AddElementReferenceCapture(9, elementReference => _spacerAfter = elementReference); builder.CloseElement(); } + private string GetSpacerStyle(int itemsInSpacer, int numItemsGapAbove) + => numItemsGapAbove == 0 + ? GetSpacerStyle(itemsInSpacer) + : $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * _itemSize).ToString(CultureInfo.InvariantCulture)}px);"; + private string GetSpacerStyle(int itemsInSpacer) => $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"; void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) { - CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity); + CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity); // Since we know the before spacer is now visible, we absolutely have to slide the window up // by at least one element. If we're not doing that, the previous item size info we had must @@ -286,12 +310,12 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer itemsBefore--; } - UpdateItemDistribution(itemsBefore, visibleItemCapacity); + UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) { - CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity); + CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity); var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity); @@ -304,7 +328,7 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS itemsBefore++; } - UpdateItemDistribution(itemsBefore, visibleItemCapacity); + UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } private void CalcualteItemDistribution( @@ -312,7 +336,8 @@ private void CalcualteItemDistribution( float spacerSeparation, float containerSize, out int itemsInSpacer, - out int visibleItemCapacity) + out int visibleItemCapacity, + out int unusedItemCapacity) { if (_lastRenderedItemCount > 0) { @@ -326,11 +351,22 @@ private void CalcualteItemDistribution( _itemSize = ItemSize; } + // This AppContext data was added as a stopgap for .NET 8 and earlier, since it was added in a patch + // where we couldn't add new public API. For backcompat we still support the AppContext setting, but + // new applications should use the much more convenient MaxItemCount parameter. + var maxItemCount = AppContext.GetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount") switch + { + int val => Math.Min(val, MaxItemCount), + _ => MaxItemCount + }; + itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / _itemSize) - OverscanCount); visibleItemCapacity = (int)Math.Ceiling(containerSize / _itemSize) + 2 * OverscanCount; + unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount); + visibleItemCapacity -= unusedItemCapacity; } - private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity) + private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity, int unusedItemCapacity) { // 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 @@ -340,10 +376,11 @@ private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity) } // If anything about the offset changed, re-render - if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity) + if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity || unusedItemCapacity != _unusedItemCapacity) { _itemsBefore = itemsBefore; _visibleItemCapacity = visibleItemCapacity; + _unusedItemCapacity = unusedItemCapacity; var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true); if (!refreshTask.IsCompleted) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index d9a29aa8b6c2..f7256289a388 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -262,6 +262,36 @@ public void CanRenderHtmlTable() Assert.Contains(expectedInitialSpacerStyle, bottomSpacer.GetAttribute("style")); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanLimitMaxItemsRendered(bool useAppContext) + { + if (useAppContext) + { + // This is to test back-compat with the switch added in a .NET 8 patch. + // Newer applications shouldn't use this technique. + Browser.MountTestComponent(); + } + else + { + Browser.MountTestComponent(); + } + + // Despite having a 600px tall scroll area and 30px high items (600/30=20), + // 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("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); + } + [Fact] public void CanMutateDataInPlace_Sync() { diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index e437aa9811d2..be5417871d06 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -109,6 +109,8 @@ + + diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor new file mode 100644 index 000000000000..181e6eeec1bd --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor @@ -0,0 +1,33 @@ +

+ MaxItemCount is a safeguard against the client reporting a giant viewport and causing the server to perform a + correspondingly giant data load and then tracking a lot of render state. +

+ +

+ If MaxItemCount is exceeded (which it never should be for a well-behaved client), we don't offer any guarantees + that the behavior will be nice for the end user. We just guarantee to limit the .NET-side workload. As such this + E2E test deliberately does a bad thing of setting MaxItemCount to a low value for test purposes. Applications + should not do this. +

+ +
+ +
+ Id: @context.Id; Name: @context.Name +
+
+
+ +@code { + private async ValueTask> GetItems(ItemsProviderRequest request) + { + const int numThings = 100000; + + await Task.Delay(100); + return new ItemsProviderResult( + Enumerable.Range(request.StartIndex, request.Count).Select(i => new MyThing(i, $"Thing {i}")), + numThings); + } + + record MyThing(int Id, string Name); +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor new file mode 100644 index 000000000000..d272859d70b6 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor @@ -0,0 +1,41 @@ +@implements IDisposable +

+ This is a variation of the VirtualizationMaxItemCount test case in which the max count is set using AppContext. + This E2E test exists only to verify back-compatibility. +

+ +
+ @* In .NET 8 and earlier, the E2E test uses an AppContext.SetData call to set MaxItemCount *@ + @* In .NET 9 onwards, it's a Virtualize component parameter *@ + +
+ Id: @context.Id; Name: @context.Name +
+
+
+ +@code { + protected override void OnInitialized() + { + // This relies on Xunit's default behavior of running tests in the same collection sequentially, + // not in parallel. From .NET 9 onwards this can be removed in favour of a Virtualize parameter. + AppContext.SetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount", 10); + } + + private async ValueTask> GetItems(ItemsProviderRequest request) + { + const int numThings = 100000; + + await Task.Delay(100); + return new ItemsProviderResult( + Enumerable.Range(request.StartIndex, request.Count).Select(i => new MyThing(i, $"Thing {i}")), + numThings); + } + + record MyThing(int Id, string Name); + + public void Dispose() + { + AppContext.SetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount", null); + } +}