Skip to content

Commit 5aee1f6

Browse files
Limit MaxItemCount in Virtualize
1 parent ba94792 commit 5aee1f6

File tree

4 files changed

+117
-8
lines changed

4 files changed

+117
-8
lines changed

src/Components/Web/src/Virtualization/Virtualize.cs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
2525

2626
private int _visibleItemCapacity;
2727

28+
// If the client reports a viewport so large that it could show more than MaxItemCount items,
29+
// we keep track of the "unused" capacity, which is the amount of blank space we want to leave
30+
// at the bottom of the viewport (as a number of items). If we didn't leave this blank space,
31+
// then the bottom spacer would always stay visible and the client would request more items in an
32+
// infinite (but asynchronous) loop, as it would believe there are more items to render and
33+
// enough space to render them into.
34+
private int _unusedItemCapacity;
35+
2836
private int _itemCount;
2937

3038
private int _loadedItemsStartIndex;
@@ -118,6 +126,22 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
118126
[Parameter]
119127
public string SpacerElement { get; set; } = "div";
120128

129+
/*
130+
This API will be added in .NET 9 but cannot be added in a .NET 8 or earlier patch,
131+
as we can't change public API in patches.
132+
133+
/// <summary>
134+
/// Gets or sets the maximum number of items that will be rendered, even if the client reports
135+
/// that its viewport is large enough to show more. The default value is 100.
136+
///
137+
/// This should only be used as a safeguard against excessive memory usage or large data loads.
138+
/// Do not set this to a smaller number than you expect to fit on a realistic-sized window, because
139+
/// that will leave a blank gap below and the user may not be able to see the rest of the content.
140+
/// </summary>
141+
[Parameter]
142+
public int MaxItemCount { get; set; } = 100;
143+
*/
144+
121145
/// <summary>
122146
/// Instructs the component to re-request data from its <see cref="ItemsProvider"/>.
123147
/// 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)
264288
var itemsAfter = Math.Max(0, _itemCount - _visibleItemCapacity - _itemsBefore);
265289

266290
builder.OpenElement(7, SpacerElement);
267-
builder.AddAttribute(8, "style", GetSpacerStyle(itemsAfter));
291+
builder.AddAttribute(8, "style", GetSpacerStyle(itemsAfter, _unusedItemCapacity));
268292
builder.AddElementReferenceCapture(9, elementReference => _spacerAfter = elementReference);
269293

270294
builder.CloseElement();
271295
}
272296

297+
private string GetSpacerStyle(int itemsInSpacer, int numItemsGapAbove)
298+
=> numItemsGapAbove == 0
299+
? GetSpacerStyle(itemsInSpacer)
300+
: $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * _itemSize).ToString(CultureInfo.InvariantCulture)}px);";
301+
273302
private string GetSpacerStyle(int itemsInSpacer)
274303
=> $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;";
275304

276305
void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
277306
{
278-
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity);
307+
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity);
279308

280309
// Since we know the before spacer is now visible, we absolutely have to slide the window up
281310
// 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
286315
itemsBefore--;
287316
}
288317

289-
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
318+
UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
290319
}
291320

292321
void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
293322
{
294-
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity);
323+
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity);
295324

296325
var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity);
297326

@@ -304,15 +333,16 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS
304333
itemsBefore++;
305334
}
306335

307-
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
336+
UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
308337
}
309338

310339
private void CalcualteItemDistribution(
311340
float spacerSize,
312341
float spacerSeparation,
313342
float containerSize,
314343
out int itemsInSpacer,
315-
out int visibleItemCapacity)
344+
out int visibleItemCapacity,
345+
out int unusedItemCapacity)
316346
{
317347
if (_lastRenderedItemCount > 0)
318348
{
@@ -326,11 +356,21 @@ private void CalcualteItemDistribution(
326356
_itemSize = ItemSize;
327357
}
328358

359+
// This AppContext data exists as a stopgap for .NET 8 and earlier, since this is being added in a patch
360+
// where we can't add new public API.
361+
var maxItemCount = AppContext.GetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount") switch
362+
{
363+
int val => val, // In .NET 9, this will be Math.Min(val, MaxItemCount)
364+
_ => 1000 // In .NET 9, this will be MaxItemCount
365+
};
366+
329367
itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / _itemSize) - OverscanCount);
330368
visibleItemCapacity = (int)Math.Ceiling(containerSize / _itemSize) + 2 * OverscanCount;
369+
unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount);
370+
visibleItemCapacity -= unusedItemCapacity;
331371
}
332372

333-
private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity)
373+
private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity, int unusedItemCapacity)
334374
{
335375
// If the itemcount just changed to a lower number, and we're already scrolled past the end of the new
336376
// reduced set of items, clamp the scroll position to the new maximum
@@ -340,10 +380,11 @@ private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity)
340380
}
341381

342382
// If anything about the offset changed, re-render
343-
if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity)
383+
if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity || unusedItemCapacity != _unusedItemCapacity)
344384
{
345385
_itemsBefore = itemsBefore;
346386
_visibleItemCapacity = visibleItemCapacity;
387+
_unusedItemCapacity = unusedItemCapacity;
347388
var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true);
348389

349390
if (!refreshTask.IsCompleted)

src/Components/test/E2ETest/Tests/VirtualizationTest.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,25 @@ public void CanRenderHtmlTable()
262262
Assert.Contains(expectedInitialSpacerStyle, bottomSpacer.GetAttribute("style"));
263263
}
264264

265+
[Fact]
266+
public void CanLimitMaxItemsRendered()
267+
{
268+
Browser.MountTestComponent<VirtualizationMaxItemCount>();
269+
270+
// Despite having a 600px tall scroll area and 30px high items (600/30=20),
271+
// we only render 10 items due to the MaxItemCount setting
272+
var scrollArea = Browser.Exists(By.Id("virtualize-scroll-area"));
273+
var getItems = () => scrollArea.FindElements(By.ClassName("my-item"));
274+
Browser.Equal(10, () => getItems().Count);
275+
Browser.Equal("Id: 0; Name: Thing 0", () => getItems().First().Text);
276+
277+
// Scrolling still works and loads new data, though there's no guarantee about
278+
// exactly how many items will show up at any one time
279+
Browser.ExecuteJavaScript("document.getElementById('virtualize-scroll-area').scrollTop = 300;");
280+
Browser.NotEqual("Id: 0; Name: Thing 0", () => getItems().First().Text);
281+
Browser.True(() => getItems().Count > 3 && getItems().Count <= 10);
282+
}
283+
265284
[Fact]
266285
public void CanMutateDataInPlace_Sync()
267286
{

src/Components/test/testassets/BasicTestApp/Index.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
<option value="BasicTestApp.TouchEventComponent">Touch events</option>
109109
<option value="BasicTestApp.VirtualizationComponent">Virtualization</option>
110110
<option value="BasicTestApp.VirtualizationDataChanges">Virtualization data changes</option>
111+
<option value="BasicTestApp.VirtualizationMaxItemCount">Virtualization MaxItemCount</option>
111112
<option value="BasicTestApp.VirtualizationTable">Virtualization HTML table</option>
112113
<option value="BasicTestApp.HotReload.RenderOnHotReload">Render on hot reload</option>
113114
<option value="BasicTestApp.SectionsTest.ParentComponentWithTwoChildren">Sections test</option>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
@implements IDisposable
2+
<p>
3+
MaxItemCount is a safeguard against the client reporting a giant viewport and causing the server to perform a
4+
correspondingly giant data load and then tracking a lot of render state.
5+
</p>
6+
7+
<p>
8+
If MaxItemCount is exceeded (which it never should be for a well-behaved client), we don't offer any guarantees
9+
that the behavior will be nice for the end user. We just guarantee to limit the .NET-side workload. As such this
10+
E2E test deliberately does a bad thing of setting MaxItemCount to a low value for test purposes. Applications
11+
should not do this.
12+
</p>
13+
14+
<div id="virtualize-scroll-area" style="height: 600px; overflow-y: scroll; outline: 1px solid red; background: #eee;">
15+
@* In .NET 8 and earlier, the E2E test uses an AppContext.SetData call to set MaxItemCount *@
16+
@* In .NET 9 onwards, it's a Virtualize component parameter *@
17+
<Virtualize ItemsProvider="GetItems" ItemSize="30">
18+
<div class="my-item" @key="context" style="height: 30px; outline: 1px solid #ccc">
19+
Id: @context.Id; Name: @context.Name
20+
</div>
21+
</Virtualize>
22+
</div>
23+
24+
@code {
25+
protected override void OnInitialized()
26+
{
27+
// This relies on Xunit's default behavior of running tests in the same collection sequentially,
28+
// not in parallel. From .NET 9 onwards this can be removed in favour of a Virtualize parameter.
29+
AppContext.SetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount", 10);
30+
}
31+
32+
private async ValueTask<ItemsProviderResult<MyThing>> GetItems(ItemsProviderRequest request)
33+
{
34+
const int numThings = 100000;
35+
36+
await Task.Delay(100);
37+
return new ItemsProviderResult<MyThing>(
38+
Enumerable.Range(request.StartIndex, request.Count).Select(i => new MyThing(i, $"Thing {i}")),
39+
numThings);
40+
}
41+
42+
record MyThing(int Id, string Name);
43+
44+
public void Dispose()
45+
{
46+
AppContext.SetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount", null);
47+
}
48+
}

0 commit comments

Comments
 (0)