Skip to content

Commit c0e0f98

Browse files
authored
Merge pull request #57434 from dotnet/merge/release/9.0-to-main
[automated] Merge branch 'release/9.0' => 'main'
2 parents 707068e + 387b17b commit c0e0f98

File tree

9 files changed

+395
-249
lines changed

9 files changed

+395
-249
lines changed

eng/Version.Details.xml

Lines changed: 160 additions & 160 deletions
Large diffs are not rendered by default.

eng/Versions.props

Lines changed: 81 additions & 81 deletions
Large diffs are not rendered by default.

src/Components/Web/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime
33
Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(string! identifier, string? argsJson, Microsoft.JSInterop.JSCallResultType resultType, long targetInstanceId) -> string!
44
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.get -> bool
55
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.set -> void
6+
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.MaxItemCount.get -> int
7+
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.MaxItemCount.set -> void
68
override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.RendererInfo.get -> Microsoft.AspNetCore.Components.RendererInfo!
79
override Microsoft.AspNetCore.Components.Routing.FocusOnNavigate.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void

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

Lines changed: 45 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,17 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
118126
[Parameter]
119127
public string SpacerElement { get; set; } = "div";
120128

129+
/// <summary>
130+
/// Gets or sets the maximum number of items that will be rendered, even if the client reports
131+
/// that its viewport is large enough to show more. The default value is 100.
132+
///
133+
/// This should only be used as a safeguard against excessive memory usage or large data loads.
134+
/// Do not set this to a smaller number than you expect to fit on a realistic-sized window, because
135+
/// that will leave a blank gap below and the user may not be able to see the rest of the content.
136+
/// </summary>
137+
[Parameter]
138+
public int MaxItemCount { get; set; } = 100;
139+
121140
/// <summary>
122141
/// Instructs the component to re-request data from its <see cref="ItemsProvider"/>.
123142
/// 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)
264283
var itemsAfter = Math.Max(0, _itemCount - _visibleItemCapacity - _itemsBefore);
265284

266285
builder.OpenElement(7, SpacerElement);
267-
builder.AddAttribute(8, "style", GetSpacerStyle(itemsAfter));
286+
builder.AddAttribute(8, "style", GetSpacerStyle(itemsAfter, _unusedItemCapacity));
268287
builder.AddElementReferenceCapture(9, elementReference => _spacerAfter = elementReference);
269288

270289
builder.CloseElement();
271290
}
272291

292+
private string GetSpacerStyle(int itemsInSpacer, int numItemsGapAbove)
293+
=> numItemsGapAbove == 0
294+
? GetSpacerStyle(itemsInSpacer)
295+
: $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * _itemSize).ToString(CultureInfo.InvariantCulture)}px);";
296+
273297
private string GetSpacerStyle(int itemsInSpacer)
274298
=> $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;";
275299

276300
void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
277301
{
278-
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity);
302+
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity);
279303

280304
// Since we know the before spacer is now visible, we absolutely have to slide the window up
281305
// 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
286310
itemsBefore--;
287311
}
288312

289-
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
313+
UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
290314
}
291315

292316
void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
293317
{
294-
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity);
318+
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity);
295319

296320
var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity);
297321

@@ -304,15 +328,16 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS
304328
itemsBefore++;
305329
}
306330

307-
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
331+
UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
308332
}
309333

310334
private void CalcualteItemDistribution(
311335
float spacerSize,
312336
float spacerSeparation,
313337
float containerSize,
314338
out int itemsInSpacer,
315-
out int visibleItemCapacity)
339+
out int visibleItemCapacity,
340+
out int unusedItemCapacity)
316341
{
317342
if (_lastRenderedItemCount > 0)
318343
{
@@ -326,11 +351,22 @@ private void CalcualteItemDistribution(
326351
_itemSize = ItemSize;
327352
}
328353

354+
// This AppContext data was added as a stopgap for .NET 8 and earlier, since it was added in a patch
355+
// where we couldn't add new public API. For backcompat we still support the AppContext setting, but
356+
// new applications should use the much more convenient MaxItemCount parameter.
357+
var maxItemCount = AppContext.GetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount") switch
358+
{
359+
int val => Math.Min(val, MaxItemCount),
360+
_ => MaxItemCount
361+
};
362+
329363
itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / _itemSize) - OverscanCount);
330364
visibleItemCapacity = (int)Math.Ceiling(containerSize / _itemSize) + 2 * OverscanCount;
365+
unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount);
366+
visibleItemCapacity -= unusedItemCapacity;
331367
}
332368

333-
private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity)
369+
private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity, int unusedItemCapacity)
334370
{
335371
// If the itemcount just changed to a lower number, and we're already scrolled past the end of the new
336372
// reduced set of items, clamp the scroll position to the new maximum
@@ -340,10 +376,11 @@ private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity)
340376
}
341377

342378
// If anything about the offset changed, re-render
343-
if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity)
379+
if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity || unusedItemCapacity != _unusedItemCapacity)
344380
{
345381
_itemsBefore = itemsBefore;
346382
_visibleItemCapacity = visibleItemCapacity;
383+
_unusedItemCapacity = unusedItemCapacity;
347384
var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true);
348385

349386
if (!refreshTask.IsCompleted)

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

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

265+
[Theory]
266+
[InlineData(true)]
267+
[InlineData(false)]
268+
public void CanLimitMaxItemsRendered(bool useAppContext)
269+
{
270+
if (useAppContext)
271+
{
272+
// This is to test back-compat with the switch added in a .NET 8 patch.
273+
// Newer applications shouldn't use this technique.
274+
Browser.MountTestComponent<VirtualizationMaxItemCount_AppContext>();
275+
}
276+
else
277+
{
278+
Browser.MountTestComponent<VirtualizationMaxItemCount>();
279+
}
280+
281+
// Despite having a 600px tall scroll area and 30px high items (600/30=20),
282+
// we only render 10 items due to the MaxItemCount setting
283+
var scrollArea = Browser.Exists(By.Id("virtualize-scroll-area"));
284+
var getItems = () => scrollArea.FindElements(By.ClassName("my-item"));
285+
Browser.Equal(10, () => getItems().Count);
286+
Browser.Equal("Id: 0; Name: Thing 0", () => getItems().First().Text);
287+
288+
// Scrolling still works and loads new data, though there's no guarantee about
289+
// exactly how many items will show up at any one time
290+
Browser.ExecuteJavaScript("document.getElementById('virtualize-scroll-area').scrollTop = 300;");
291+
Browser.NotEqual("Id: 0; Name: Thing 0", () => getItems().First().Text);
292+
Browser.True(() => getItems().Count > 3 && getItems().Count <= 10);
293+
}
294+
265295
[Fact]
266296
public void CanMutateDataInPlace_Sync()
267297
{

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

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

src/Servers/HttpSys/perf/Microbenchmarks/Microsoft.AspNetCore.Server.HttpSys.Microbenchmarks.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
88
<TieredCompilation>false</TieredCompilation>
99
<DefineConstants>$(DefineConstants);IS_BENCHMARKS</DefineConstants>
10+
<SkipMicrobenchmarksValidation>true</SkipMicrobenchmarksValidation>
1011
</PropertyGroup>
1112

1213
<ItemGroup>

0 commit comments

Comments
 (0)