Skip to content

Forward-port the Virtualize MaxItemCount added in .NET 8 patch to .NET 9 RC1 #57400

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<TItem>.MaxItemCount.get -> int
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.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
53 changes: 45 additions & 8 deletions src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ public sealed class Virtualize<TItem> : 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;
Expand Down Expand Up @@ -118,6 +126,17 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
[Parameter]
public string SpacerElement { get; set; } = "div";

/// <summary>
/// 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.
/// </summary>
[Parameter]
public int MaxItemCount { get; set; } = 100;

/// <summary>
/// Instructs the component to re-request data from its <see cref="ItemsProvider"/>.
/// This is useful if external data may have changed. There is no need to call this
Expand Down Expand Up @@ -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
Expand All @@ -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);

Expand All @@ -304,15 +328,16 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS
itemsBefore++;
}

UpdateItemDistribution(itemsBefore, visibleItemCapacity);
UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
}

private void CalcualteItemDistribution(
float spacerSize,
float spacerSeparation,
float containerSize,
out int itemsInSpacer,
out int visibleItemCapacity)
out int visibleItemCapacity,
out int unusedItemCapacity)
{
if (_lastRenderedItemCount > 0)
{
Expand All @@ -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
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions src/Components/test/E2ETest/Tests/VirtualizationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<VirtualizationMaxItemCount_AppContext>();
}
else
{
Browser.MountTestComponent<VirtualizationMaxItemCount>();
}

// 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()
{
Expand Down
2 changes: 2 additions & 0 deletions src/Components/test/testassets/BasicTestApp/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@
<option value="BasicTestApp.TouchEventComponent">Touch events</option>
<option value="BasicTestApp.VirtualizationComponent">Virtualization</option>
<option value="BasicTestApp.VirtualizationDataChanges">Virtualization data changes</option>
<option value="BasicTestApp.VirtualizationMaxItemCount">Virtualization MaxItemCount</option>
<option value="BasicTestApp.VirtualizationMaxItemCount_AppContext">Virtualization MaxItemCount (via AppContext)</option>
<option value="BasicTestApp.VirtualizationTable">Virtualization HTML table</option>
<option value="BasicTestApp.HotReload.RenderOnHotReload">Render on hot reload</option>
<option value="BasicTestApp.SectionsTest.ParentComponentWithTwoChildren">Sections test</option>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<p>
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.
</p>

<p>
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.
</p>

<div id="virtualize-scroll-area" style="height: 600px; overflow-y: scroll; outline: 1px solid red; background: #eee;">
<Virtualize ItemsProvider="GetItems" ItemSize="30" MaxItemCount="10">
<div class="my-item" @key="context" style="height: 30px; outline: 1px solid #ccc">
Id: @context.Id; Name: @context.Name
</div>
</Virtualize>
</div>

@code {
private async ValueTask<ItemsProviderResult<MyThing>> GetItems(ItemsProviderRequest request)
{
const int numThings = 100000;

await Task.Delay(100);
return new ItemsProviderResult<MyThing>(
Enumerable.Range(request.StartIndex, request.Count).Select(i => new MyThing(i, $"Thing {i}")),
numThings);
}

record MyThing(int Id, string Name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@implements IDisposable
<p>
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.
</p>

<div id="virtualize-scroll-area" style="height: 600px; overflow-y: scroll; outline: 1px solid red; background: #eee;">
@* 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 *@
<Virtualize ItemsProvider="GetItems" ItemSize="30">
<div class="my-item" @key="context" style="height: 30px; outline: 1px solid #ccc">
Id: @context.Id; Name: @context.Name
</div>
</Virtualize>
</div>

@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<ItemsProviderResult<MyThing>> GetItems(ItemsProviderRequest request)
{
const int numThings = 100000;

await Task.Delay(100);
return new ItemsProviderResult<MyThing>(
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);
}
}
Loading