Skip to content

Commit 7487630

Browse files
Merged PR 40874: Limit MaxItemCount in Virtualize (8.0)
# Limit MaxItemCount in Virtualize Limits how much data Virtualize will load by default. ## Description Ensures that `<Virtualize>` won't unexpectedly load or render a huge amount of data. Previously, a badly-behaved client could force it to load `int.MaxValue / ItemSize` items (if the underlying data store has that much data in it), where `ItemSize` is typically around 50. Fixes MSRC case 88893 ## Customer Impact Addresses a reported issue whereby a badly-behaved client may report an arbitrarily-large viewport size, causing the server-side `Virtualize` component to perform a correspondingly large data load, and then to hold rendertree data in memory corresponding to this many items. ## Regression? - [ ] Yes - [x] No [If yes, specify the version the behavior has regressed from] ## Risk - [ ] High - [ ] Medium - [x] Low By default the max items is set to 1000, which is way more than would normally be visible on any realistic screen. Typical per-item size is at least 20px, so unless someone's screen is > 20,000 pixels tall, they wouldn't exceed this new default maximum. The logic is structured so that, if the client's viewport does not exceed this maximum, then no behavioral change should occur. ## Verification - [x] Manual (required) - [x] Automated ## Packaging changes reviewed? - [ ] Yes - [ ] No - [x] N/A --- Note to reviewers: this supersedes https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore/pullrequest/40774 because it's from a different branch for 8.0.
2 parents a36a4e9 + 5aee1f6 commit 7487630

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)