Virtualize component: method to trigger data refresh (#26177)

* E2E test cases for Virtualize data refresh

* Expose public RefreshDataAsync API

* Optimize: don't instantiate CancellationTokenSource when it won't be used

* For in-memory data, refresh automatically on each render cycle

* Fix typo
This commit is contained in:
Steve Sanderson 2020-09-22 19:52:16 +01:00 committed by GitHub
parent c0bd50075a
commit 7140f7cae7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 271 additions and 10 deletions

View File

@ -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;
/// <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
/// when using <see cref="Items"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the completion of the operation.</returns>
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);
}
/// <inheritdoc />
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)

View File

@ -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<ToggleExecutionModeServerFixture<Program>>
public class VirtualizationTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
{
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<VirtualizationComponent>();
}
[Fact]
public void AlwaysFillsVisibleCapacity_Sync()
{
Browser.MountTestComponent<VirtualizationComponent>();
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<VirtualizationComponent>();
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<VirtualizationComponent>();
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<VirtualizationComponent>();
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<VirtualizationComponent>();
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<VirtualizationComponent>();
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<VirtualizationDataChanges>();
// 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<VirtualizationDataChanges>();
// 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<VirtualizationDataChanges>();
// 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<VirtualizationDataChanges>();
// 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<VirtualizationDataChanges>();
// 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();
}
}
}

View File

@ -13,6 +13,7 @@
<option value="BasicTestApp.ComponentRefComponent">Component ref component</option>
<option value="BasicTestApp.ConcurrentRenderParent">Concurrent rendering</option>
<option value="BasicTestApp.ConfigurationComponent">Configuration</option>
<option value="BasicTestApp.ContentEditable">Content-editable</option>
<option value="BasicTestApp.CounterComponent">Counter</option>
<option value="BasicTestApp.CounterComponentUsingChild">Counter using child component</option>
<option value="BasicTestApp.CounterComponentWrapper">Counter wrapped in parent</option>
@ -80,16 +81,16 @@
<option value="BasicTestApp.RouterTest.TestRouterWithOnNavigate">Router with OnNavigate</option>
<option value="BasicTestApp.RouterTest.TestRouterWithLazyAssembly">Router with dynamic assembly</option>
<option value="BasicTestApp.RouterTest.TestRouterWithAdditionalAssembly">Router with additional assembly</option>
<option value="BasicTestApp.SelectVariantsComponent">Select with component options</option>
<option value="BasicTestApp.SignalRClientComponent">SignalR client</option>
<option value="BasicTestApp.StringComparisonComponent">StringComparison</option>
<option value="BasicTestApp.SvgComponent">SVG</option>
<option value="BasicTestApp.SvgWithChildComponent">SVG with child component</option>
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
<option value="BasicTestApp.ToggleEventComponent">Toggle Event</option>
<option value="BasicTestApp.TouchEventComponent">Touch events</option>
<option value="BasicTestApp.VirtualizationComponent">Virtualization</option>
<option value="BasicTestApp.SelectVariantsComponent">Select with component options</option>
<option value="BasicTestApp.ToggleEventComponent">Toggle Event</option>
<option value="BasicTestApp.ContentEditable">Content-editable</option>
<option value="BasicTestApp.VirtualizationDataChanges">Virtualization data changes</option>
</select>
<span id="runtime-info"><code><tt>@System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription</tt></code></span>

View File

@ -0,0 +1,84 @@
<h3 id="virtualization-data-changes">Virtualization data changes</h3>
<p>This scenario shows how the data behind a Virtualize component can change.</p>
<button id="add-person-to-fixed-list" @onclick="AddPersonToFixedList">Add person</button>
<h4>Using Items parameter</h4>
<div id="using-items" style="overflow-y: auto; height: 200px; border: 1px dashed gray;">
<Virtualize Items="@fixedPeople" Context="person">
@*
Note that for best performance, you really should use @key on the top-level elements within the <Virtualize>.
This test case doesn't do so only as a way of verifying it's not strictly required (though it is recommended).
*@
<div class="person" style="border-bottom: 1px dashed red; display: flex; justify-content: space-between; padding: 4px;">
<span>@person.Name</span>
<button @onclick="@person.Mutate">Mutate</button>
</div>
</Virtualize>
</div>
<h4>Using ItemsProvider parameter</h4>
<p>
<button id="add-person-to-itemsprovider" @onclick="AddPersonToItemsProvider">Add person</button>
<button id="refresh-itemsprovider" @onclick="() => asyncVirtualize.RefreshDataAsync()">Refresh</button>
</p>
<div id="using-itemsprovider" style="overflow-y: auto; height: 200px; border: 1px dashed gray;">
<Virtualize @ref="asyncVirtualize" ItemsProvider="GetPeopleAsync" Context="person">
@*
Note that for best performance, you really should use @key on the top-level elements within the <Virtualize>.
This test case doesn't do so only as a way of verifying it's not strictly required (though it is recommended).
*@
<div class="person" style="border-bottom: 1px dashed red; display: flex; justify-content: space-between; padding: 4px;">
<span>@person.Name</span>
<button @onclick="@person.Mutate">Mutate</button>
</div>
</Virtualize>
</div>
@code {
Virtualize<Person> asyncVirtualize;
List<Person> 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<ItemsProviderResult<Person>> GetPeopleAsync(ItemsProviderRequest request)
{
await Task.Delay(500);
var lastIndexExcl = Math.Min(request.StartIndex + request.Count, numPeopleInItemsProvider);
return new ItemsProviderResult<Person>(
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}" };
}