Skip to content

[automated] Merge branch 'release/9.0' => 'main' #57434

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 4 commits into from
Aug 20, 2024
Merged
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
320 changes: 160 additions & 160 deletions eng/Version.Details.xml

Large diffs are not rendered by default.

162 changes: 81 additions & 81 deletions eng/Versions.props

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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
@@ -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;
@@ -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
@@ -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
@@ -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);

@@ -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)
{
@@ -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
@@ -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)
30 changes: 30 additions & 0 deletions src/Components/test/E2ETest/Tests/VirtualizationTest.cs
Original file line number Diff line number Diff line change
@@ -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()
{
2 changes: 2 additions & 0 deletions src/Components/test/testassets/BasicTestApp/Index.razor
Original file line number Diff line number Diff line change
@@ -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>
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);
}
}
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<TieredCompilation>false</TieredCompilation>
<DefineConstants>$(DefineConstants);IS_BENCHMARKS</DefineConstants>
<SkipMicrobenchmarksValidation>true</SkipMicrobenchmarksValidation>
</PropertyGroup>

<ItemGroup>
Loading