From 7140f7cae7af45ccf0e3b51ae3f4745c0c890c71 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 22 Sep 2020 19:52:16 +0100 Subject: [PATCH] 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 --- .../Web/src/Virtualization/Virtualize.cs | 49 +++++- .../test/E2ETest/Tests/VirtualizationTest.cs | 141 +++++++++++++++++- .../test/testassets/BasicTestApp/Index.razor | 7 +- .../VirtualizationDataChanges.razor | 84 +++++++++++ 4 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 src/Components/test/testassets/BasicTestApp/VirtualizationDataChanges.razor 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}" }; +}