From c2eaa6adb9575811dcb67d2135eaed981c99a568 Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Wed, 26 Jun 2024 15:55:52 +0100
Subject: [PATCH 1/2] Limit MaxItemCount in Virtualize
---
.../Web/src/Virtualization/Virtualize.cs | 57 ++++++++++++++++---
.../test/E2ETest/Tests/VirtualizationTest.cs | 19 +++++++
.../test/testassets/BasicTestApp/Index.razor | 1 +
.../VirtualizationMaxItemCount.razor | 48 ++++++++++++++++
4 files changed, 117 insertions(+), 8 deletions(-)
create mode 100644 src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor
diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs
index 61f22da4a73a..b0873be320cd 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,22 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I
[Parameter]
public string SpacerElement { get; set; } = "div";
+ /*
+ This API will be added in .NET 9 but cannot be added in a .NET 8 or earlier patch,
+ as we can't change public API in patches.
+
+ ///
+ /// 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 +288,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 +315,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 +333,7 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS
itemsBefore++;
}
- UpdateItemDistribution(itemsBefore, visibleItemCapacity);
+ UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
}
private void CalcualteItemDistribution(
@@ -312,7 +341,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 +356,21 @@ private void CalcualteItemDistribution(
_itemSize = ItemSize;
}
+ // This AppContext data exists as a stopgap for .NET 8 and earlier, since this is being added in a patch
+ // where we can't add new public API.
+ var maxItemCount = AppContext.GetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount") switch
+ {
+ int val => val, // In .NET 9, this will be Math.Min(val, MaxItemCount)
+ _ => 1000 // In .NET 9, this will be 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 +380,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..11e9d901b29b 100644
--- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
+++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
@@ -262,6 +262,25 @@ public void CanRenderHtmlTable()
Assert.Contains(expectedInitialSpacerStyle, bottomSpacer.GetAttribute("style"));
}
+ [Fact]
+ public void CanLimitMaxItemsRendered()
+ {
+ 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..d782b2e7fe68 100644
--- a/src/Components/test/testassets/BasicTestApp/Index.razor
+++ b/src/Components/test/testassets/BasicTestApp/Index.razor
@@ -109,6 +109,7 @@
+
diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor
new file mode 100644
index 000000000000..3c3d793829f0
--- /dev/null
+++ b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor
@@ -0,0 +1,48 @@
+@implements IDisposable
+
+ 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.
+
+
+
+ @* 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);
+ }
+}
From 3061fe394b40452d7afa34d4e3d6d0ee2667e01c Mon Sep 17 00:00:00 2001
From: Steve Sanderson
Date: Mon, 19 Aug 2024 11:52:33 +0100
Subject: [PATCH 2/2] Add public API for MaxItemCount
---
.../Web/src/PublicAPI.Unshipped.txt | 2 +
.../Web/src/Virtualization/Virtualize.cs | 14 +++----
.../test/E2ETest/Tests/VirtualizationTest.cs | 17 ++++++--
.../test/testassets/BasicTestApp/Index.razor | 1 +
.../VirtualizationMaxItemCount.razor | 19 +--------
...irtualizationMaxItemCount_AppContext.razor | 41 +++++++++++++++++++
6 files changed, 65 insertions(+), 29 deletions(-)
create mode 100644 src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor
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 b0873be320cd..da5de296d8c6 100644
--- a/src/Components/Web/src/Virtualization/Virtualize.cs
+++ b/src/Components/Web/src/Virtualization/Virtualize.cs
@@ -126,10 +126,6 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I
[Parameter]
public string SpacerElement { get; set; } = "div";
- /*
- This API will be added in .NET 9 but cannot be added in a .NET 8 or earlier patch,
- as we can't change public API in patches.
-
///
/// 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.
@@ -140,7 +136,6 @@ as we can't change public API in patches.
///
[Parameter]
public int MaxItemCount { get; set; } = 100;
- */
///
/// Instructs the component to re-request data from its .
@@ -356,12 +351,13 @@ private void CalcualteItemDistribution(
_itemSize = ItemSize;
}
- // This AppContext data exists as a stopgap for .NET 8 and earlier, since this is being added in a patch
- // where we can't add new public API.
+ // 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 => val, // In .NET 9, this will be Math.Min(val, MaxItemCount)
- _ => 1000 // In .NET 9, this will be MaxItemCount
+ int val => Math.Min(val, MaxItemCount),
+ _ => MaxItemCount
};
itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / _itemSize) - OverscanCount);
diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
index 11e9d901b29b..f7256289a388 100644
--- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
+++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
@@ -262,10 +262,21 @@ public void CanRenderHtmlTable()
Assert.Contains(expectedInitialSpacerStyle, bottomSpacer.GetAttribute("style"));
}
- [Fact]
- public void CanLimitMaxItemsRendered()
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void CanLimitMaxItemsRendered(bool useAppContext)
{
- Browser.MountTestComponent();
+ 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
diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor
index d782b2e7fe68..be5417871d06 100644
--- a/src/Components/test/testassets/BasicTestApp/Index.razor
+++ b/src/Components/test/testassets/BasicTestApp/Index.razor
@@ -110,6 +110,7 @@
+
diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor
index 3c3d793829f0..181e6eeec1bd 100644
--- a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor
+++ b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor
@@ -1,5 +1,4 @@
-@implements IDisposable
-
+
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.
@@ -12,9 +11,7 @@
- @* 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
@@ -22,13 +19,6 @@
@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;
@@ -40,9 +30,4 @@
}
record MyThing(int Id, string Name);
-
- public void Dispose()
- {
- AppContext.SetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount", null);
- }
}
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);
+ }
+}