diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs
index 77606d7f3c..02395e5abb 100644
--- a/src/Components/Web/src/Virtualization/Virtualize.cs
+++ b/src/Components/Web/src/Virtualization/Virtualize.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -97,6 +98,20 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization
[Parameter]
public int OverscanCount { get; set; } = 3;
+ ///
+ /// Instructs the component to re-request data from its .
+ /// This is useful if external data may have changed. There is no need to call this
+ /// when using .
+ ///
+ /// A representing the completion of the operation.
+ public async Task RefreshDataAsync()
+ {
+ // We don't auto-render after this operation because in the typical use case, the
+ // host component calls this from one of its lifecycle methods, and will naturally
+ // re-render afterwards anyway. It's not desirable to re-render twice.
+ await RefreshDataCoreAsync(renderOnSuccess: false);
+ }
+
///
protected override void OnParametersSet()
{
@@ -125,6 +140,14 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization
else if (Items != null)
{
_itemsProvider = DefaultItemsProvider;
+
+ // When we have a fixed set of in-memory data, it doesn't cost anything to
+ // re-query it on each cycle, so do that. This means the developer can add/remove
+ // items in the collection and see the UI update without having to call RefreshDataAsync.
+ var refreshTask = RefreshDataCoreAsync(renderOnSuccess: false);
+
+ // We know it's synchronous and has its own error handling
+ Debug.Assert(refreshTask.IsCompletedSuccessfully);
}
else
{
@@ -270,7 +293,7 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
_itemsBefore = itemsBefore;
_visibleItemCapacity = visibleItemCapacity;
- var refreshTask = RefreshDataAsync();
+ var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true);
if (!refreshTask.IsCompleted)
{
@@ -279,12 +302,25 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization
}
}
- private async Task RefreshDataAsync()
+ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess)
{
_refreshCts?.Cancel();
- _refreshCts = new CancellationTokenSource();
+ CancellationToken cancellationToken;
+
+ if (_itemsProvider == DefaultItemsProvider)
+ {
+ // If we're using the DefaultItemsProvider (because the developer supplied a fixed
+ // Items collection) we know it will complete synchronously, and there's no point
+ // instantiating a new CancellationTokenSource
+ _refreshCts = null;
+ cancellationToken = CancellationToken.None;
+ }
+ else
+ {
+ _refreshCts = new CancellationTokenSource();
+ cancellationToken = _refreshCts.Token;
+ }
- var cancellationToken = _refreshCts.Token;
var request = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken);
try
@@ -298,7 +334,10 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization
_loadedItems = result.Items;
_loadedItemsStartIndex = request.StartIndex;
- StateHasChanged();
+ if (renderOnSuccess)
+ {
+ StateHasChanged();
+ }
}
}
catch (Exception e)
diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
index cb6b71d742..8c15e5b7ba 100644
--- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
+++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs
@@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+using System.Linq;
using BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
@@ -13,7 +14,7 @@ using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
- public class VirtualizationTest : E2ETest.Infrastructure.ServerTestBase>
+ public class VirtualizationTest : ServerTestBase>
{
public VirtualizationTest(
BrowserFixture browserFixture,
@@ -26,12 +27,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client);
- Browser.MountTestComponent();
}
[Fact]
public void AlwaysFillsVisibleCapacity_Sync()
{
+ Browser.MountTestComponent();
var topSpacer = Browser.FindElement(By.Id("sync-container")).FindElement(By.TagName("div"));
var expectedInitialSpacerStyle = "height: 0px;";
@@ -61,6 +62,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
[Fact]
public void AlwaysFillsVisibleCapacity_Async()
{
+ Browser.MountTestComponent();
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
// Check that no items or placeholders are visible.
@@ -112,6 +114,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
[Fact]
public void RerendersWhenItemSizeShrinks_Sync()
{
+ Browser.MountTestComponent();
int initialItemCount = 0;
// Wait until items have been rendered.
@@ -131,6 +134,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
[Fact]
public void RerendersWhenItemSizeShrinks_Async()
{
+ Browser.MountTestComponent();
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
// Load the initial set of items.
@@ -165,6 +169,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
[Fact]
public void CancelsOutdatedRefreshes_Async()
{
+ Browser.MountTestComponent();
var cancellationCount = Browser.FindElement(By.Id("cancellation-count"));
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
@@ -191,6 +196,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
[QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/24922")]
public void CanUseViewportAsContainer()
{
+ Browser.MountTestComponent();
var expectedInitialSpacerStyle = "height: 0px;";
var topSpacer = Browser.FindElement(By.Id("viewport-as-root")).FindElement(By.TagName("div"));
@@ -204,5 +210,136 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
// Validate that the top spacer has expanded.
Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
}
+
+ [Fact]
+ public void CanMutateDataInPlace_Sync()
+ {
+ Browser.MountTestComponent();
+
+ // Initial data
+ var container = Browser.FindElement(By.Id("using-items"));
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name));
+
+ // Mutate one of them
+ var itemToMutate = container.FindElements(By.ClassName("person"))[1];
+ itemToMutate.FindElement(By.TagName("button")).Click();
+
+ // See changes
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2 MUTATED", name),
+ name => Assert.Equal("Person 3", name));
+ }
+
+ [Fact]
+ public void CanMutateDataInPlace_Async()
+ {
+ Browser.MountTestComponent();
+
+ // Initial data
+ var container = Browser.FindElement(By.Id("using-itemsprovider"));
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name));
+
+ // Mutate one of them
+ var itemToMutate = container.FindElements(By.ClassName("person"))[1];
+ itemToMutate.FindElement(By.TagName("button")).Click();
+
+ // See changes
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2 MUTATED", name),
+ name => Assert.Equal("Person 3", name));
+ }
+
+ [Fact]
+ public void CanChangeDataCount_Sync()
+ {
+ Browser.MountTestComponent();
+
+ // Initial data
+ var container = Browser.FindElement(By.Id("using-items"));
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name));
+
+ // Add another item
+ Browser.FindElement(By.Id("add-person-to-fixed-list")).Click();
+
+ // See changes
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name),
+ name => Assert.Equal("Person 4", name));
+ }
+
+ [Fact]
+ public void CanChangeDataCount_Async()
+ {
+ Browser.MountTestComponent();
+
+ // Initial data
+ var container = Browser.FindElement(By.Id("using-itemsprovider"));
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name));
+
+ // Add another item
+ Browser.FindElement(By.Id("add-person-to-itemsprovider")).Click();
+
+ // Initially this has no effect because we don't re-query the provider until told to do so
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name));
+
+ // Request refresh
+ Browser.FindElement(By.Id("refresh-itemsprovider")).Click();
+
+ // See changes
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name),
+ name => Assert.Equal("Person 4", name));
+ }
+
+ [Fact]
+ public void CanRefreshItemsProviderResultsInPlace()
+ {
+ Browser.MountTestComponent();
+
+ // Mutate the data
+ var container = Browser.FindElement(By.Id("using-itemsprovider"));
+ var itemToMutate = container.FindElements(By.ClassName("person"))[1];
+ itemToMutate.FindElement(By.TagName("button")).Click();
+
+ // Verify the mutation was applied
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2 MUTATED", name),
+ name => Assert.Equal("Person 3", name));
+
+ // Refresh and verify the mutation was reverted
+ Browser.FindElement(By.Id("refresh-itemsprovider")).Click();
+ Browser.Collection(() => GetPeopleNames(container),
+ name => Assert.Equal("Person 1", name),
+ name => Assert.Equal("Person 2", name),
+ name => Assert.Equal("Person 3", name));
+ }
+
+ private string[] GetPeopleNames(IWebElement container)
+ {
+ var peopleElements = container.FindElements(By.CssSelector(".person span"));
+ return peopleElements.Select(element => element.Text).ToArray();
+ }
}
}
diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor
index 99379de0bd..23d3098bc3 100644
--- a/src/Components/test/testassets/BasicTestApp/Index.razor
+++ b/src/Components/test/testassets/BasicTestApp/Index.razor
@@ -13,6 +13,7 @@
+
@@ -80,16 +81,16 @@
+
+
-
-
-
+
@System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription
diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor
new file mode 100644
index 0000000000..0c409fe2e4
--- /dev/null
+++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor
@@ -0,0 +1,84 @@
+
Virtualization data changes
+
+
This scenario shows how the data behind a Virtualize component can change.
+
+
+
+
Using Items parameter
+
+
+
+ @*
+ Note that for best performance, you really should use @key on the top-level elements within the .
+ This test case doesn't do so only as a way of verifying it's not strictly required (though it is recommended).
+ *@
+
+ @person.Name
+
+
+
+
+
+
Using ItemsProvider parameter
+
+
+
+
+
+
+
+ @*
+ Note that for best performance, you really should use @key on the top-level elements within the .
+ This test case doesn't do so only as a way of verifying it's not strictly required (though it is recommended).
+ *@
+
+ @person.Name
+
+
+
+
+
+@code {
+ Virtualize asyncVirtualize;
+ List fixedPeople = Enumerable.Range(1, 3).Select(GeneratePerson).ToList();
+ int numPeopleInItemsProvider = 3;
+
+ void AddPersonToFixedList()
+ {
+ // When using Items (not ItemsProvider), the Virtualize component re-queries
+ // the data on every refresh cycle without requiring you to call RefreshDataAsync.
+ // This is because there's no cost involved in doing so. Thus, the following
+ // line is enough to make the UI change on its own.
+ fixedPeople.Add(GeneratePerson(fixedPeople.Count + 1));
+ }
+
+ void AddPersonToItemsProvider()
+ {
+ // On its own, this isn't going to make the UI change, because it doesn't know
+ // to re-query the underlying items provider until you call RefreshDataAsync.
+ numPeopleInItemsProvider++;
+ }
+
+ async ValueTask> GetPeopleAsync(ItemsProviderRequest request)
+ {
+ await Task.Delay(500);
+
+ var lastIndexExcl = Math.Min(request.StartIndex + request.Count, numPeopleInItemsProvider);
+ return new ItemsProviderResult(
+ Enumerable.Range(1 + request.StartIndex, lastIndexExcl - request.StartIndex).Select(GeneratePerson).ToList(),
+ numPeopleInItemsProvider);
+ }
+
+ class Person
+ {
+ public string Name { get; set; }
+
+ public void Mutate()
+ {
+ Name += " MUTATED";
+ }
+ }
+
+ static Person GeneratePerson(int index)
+ => new Person { Name = $"Person {index}" };
+}