Virtualization support (#24179)

This commit is contained in:
Mackinnon Buck 2020-08-03 17:02:12 -07:00 committed by GitHub
parent 3f15d26851
commit 4ef5e10e6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1011 additions and 5 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,7 @@
import { navigateTo, internalFunctions as navigationManagerInternalFunctions } from './Services/NavigationManager';
import { attachRootComponentToElement } from './Rendering/Renderer';
import { domFunctions } from './DomWrapper';
import { Virtualize } from './Virtualize';
// Make the following APIs available in global scope for invocation from JS
window['Blazor'] = {
@ -10,5 +11,6 @@ window['Blazor'] = {
attachRootComponentToElement,
navigationManager: navigationManagerInternalFunctions,
domWrapper: domFunctions,
Virtualize,
},
};

View File

@ -0,0 +1,86 @@
export const Virtualize = {
init,
dispose,
};
const observersByDotNetId = {};
function findClosestScrollContainer(element: Element | null): Element | null {
if (!element) {
return null;
}
const style = getComputedStyle(element);
if (style.overflowY !== 'visible') {
return element;
}
return findClosestScrollContainer(element.parentElement);
}
function init(dotNetHelper: any, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50): void {
const intersectionObserver = new IntersectionObserver(intersectionCallback, {
root: findClosestScrollContainer(spacerBefore),
rootMargin: `${rootMargin}px`,
});
intersectionObserver.observe(spacerBefore);
intersectionObserver.observe(spacerAfter);
const mutationObserverBefore = createSpacerMutationObserver(spacerBefore);
const mutationObserverAfter = createSpacerMutationObserver(spacerAfter);
observersByDotNetId[dotNetHelper._id] = {
intersectionObserver,
mutationObserverBefore,
mutationObserverAfter,
};
function createSpacerMutationObserver(spacer: HTMLElement): MutationObserver {
// Without the use of thresholds, IntersectionObserver only detects binary changes in visibility,
// so if a spacer gets resized but remains visible, no additional callbacks will occur. By unobserving
// and reobserving spacers when they get resized, the intersection callback will re-run if they remain visible.
const mutationObserver = new MutationObserver((): void => {
intersectionObserver.unobserve(spacer);
intersectionObserver.observe(spacer);
});
mutationObserver.observe(spacer, { attributes: true });
return mutationObserver;
}
function intersectionCallback(entries: IntersectionObserverEntry[]): void {
entries.forEach((entry): void => {
if (!entry.isIntersecting) {
return;
}
const containerSize = entry.rootBounds?.height;
if (entry.target === spacerBefore) {
dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', entry.intersectionRect.top - entry.boundingClientRect.top, containerSize);
} else if (entry.target === spacerAfter && spacerAfter.offsetHeight > 0) {
// When we first start up, both the "before" and "after" spacers will be visible, but it's only relevant to raise a
// single event to load the initial data. To avoid raising two events, skip the one for the "after" spacer if we know
// it's meaningless to talk about any overlap into it.
dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', entry.boundingClientRect.bottom - entry.intersectionRect.bottom, containerSize);
}
});
}
}
function dispose(dotNetHelper: any): void {
const observers = observersByDotNetId[dotNetHelper._id];
if (observers) {
observers.intersectionObserver.disconnect();
observers.mutationObserverBefore.disconnect();
observers.mutationObserverAfter.disconnect();
dotNetHelper.dispose();
delete observersByDotNetId[dotNetHelper._id];
}
}

View File

@ -0,0 +1,11 @@
// 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.
namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
internal interface IVirtualizeJsCallbacks
{
void OnBeforeSpacerVisible(float spacerSize, float containerSize);
void OnAfterSpacerVisible(float spacerSize, float containerSize);
}
}

View File

@ -0,0 +1,15 @@
// 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.Threading.Tasks;
namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
/// <summary>
/// A function that provides items to a virtualized source.
/// </summary>
/// <typeparam name="TItem">The type of the context for each item in the list.</typeparam>
/// <param name="request">The <see cref="ItemsProviderRequest"/> defining the request details.</param>
/// <returns>A <see cref="ValueTask"/> whose result is a <see cref="ItemsProviderResult{TItem}"/> upon successful completion.</returns>
public delegate ValueTask<ItemsProviderResult<TItem>> ItemsProviderDelegate<TItem>(ItemsProviderRequest request);
}

View File

@ -0,0 +1,44 @@
// 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.Threading;
namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
/// <summary>
/// Represents a request to an <see cref="ItemsProviderDelegate{TItem}"/>.
/// </summary>
public readonly struct ItemsProviderRequest
{
/// <summary>
/// The start index of the data segment requested.
/// </summary>
public int StartIndex { get; }
/// <summary>
/// The requested number of items to be provided. The actual number of provided items does not need to match
/// this value.
/// </summary>
public int Count { get; }
/// <summary>
/// The <see cref="System.Threading.CancellationToken"/> used to relay cancellation of the request.
/// </summary>
public CancellationToken CancellationToken { get; }
/// <summary>
/// Constructs a new <see cref="ItemsProviderRequest"/> instance.
/// </summary>
/// <param name="startIndex">The start index of the data segment requested.</param>
/// <param name="count">The requested number of items to be provided.</param>
/// <param name="cancellationToken">
/// The <see cref="System.Threading.CancellationToken"/> used to relay cancellation of the request.
/// </param>
public ItemsProviderRequest(int startIndex, int count, CancellationToken cancellationToken)
{
StartIndex = startIndex;
Count = count;
CancellationToken = cancellationToken;
}
}
}

View File

@ -0,0 +1,35 @@
// 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.Collections.Generic;
namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
/// <summary>
/// Represents the result of a <see cref="ItemsProviderDelegate{TItem}"/>.
/// </summary>
/// <typeparam name="TItem">The type of the context for each item in the list.</typeparam>
public readonly struct ItemsProviderResult<TItem>
{
/// <summary>
/// The items to provide.
/// </summary>
public IEnumerable<TItem> Items { get; }
/// <summary>
/// The total item count in the source generating the items provided.
/// </summary>
public int TotalItemCount { get; }
/// <summary>
/// Instantiates a new <see cref="ItemsProviderResult{TItem}"/> instance.
/// </summary>
/// <param name="items">The items to provide.</param>
/// <param name="totalItemCount">The total item count in the source generating the items provided.</param>
public ItemsProviderResult(IEnumerable<TItem> items, int totalItemCount)
{
Items = items;
TotalItemCount = totalItemCount;
}
}
}

View File

@ -0,0 +1,25 @@
// 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.
namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
/// <summary>
/// Contains context for a placeholder in a virtualized list.
/// </summary>
public readonly struct PlaceholderContext
{
/// <summary>
/// The item index of the placeholder.
/// </summary>
public int Index { get; }
/// <summary>
/// Constructs a new <see cref="PlaceholderContext"/> instance.
/// </summary>
/// <param name="index">The item index of the placeholder.</param>
public PlaceholderContext(int index)
{
Index = index;
}
}
}

View File

@ -0,0 +1,303 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.JSInterop;
namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
/// <summary>
/// Provides functionality for rendering a virtualized list of items.
/// </summary>
/// <typeparam name="TItem">The <c>context</c> type for the items being rendered.</typeparam>
public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, IAsyncDisposable
{
private VirtualizeJsInterop? _jsInterop;
private ElementReference _spacerBefore;
private ElementReference _spacerAfter;
private int _itemsBefore;
private int _visibleItemCapacity;
private int _itemCount;
private int _loadedItemsStartIndex;
private IEnumerable<TItem>? _loadedItems;
private CancellationTokenSource? _refreshCts;
private Exception? _refreshException;
private ItemsProviderDelegate<TItem> _itemsProvider = default!;
private RenderFragment<TItem>? _itemTemplate;
private RenderFragment<PlaceholderContext>? _placeholder;
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
/// <summary>
/// Gets or sets the item template for the list.
/// </summary>
[Parameter]
public RenderFragment<TItem>? ChildContent { get; set; }
/// <summary>
/// Gets or sets the item template for the list.
/// </summary>
[Parameter]
public RenderFragment<TItem>? ItemContent { get; set; }
/// <summary>
/// Gets or sets the template for items that have not yet been loaded in memory.
/// </summary>
[Parameter]
public RenderFragment<PlaceholderContext>? Placeholder { get; set; }
/// <summary>
/// Gets the size of each item in pixels.
/// </summary>
[Parameter]
public float ItemSize { get; set; }
/// <summary>
/// Gets or sets the function providing items to the list.
/// </summary>
[Parameter]
public ItemsProviderDelegate<TItem>? ItemsProvider { get; set; }
/// <summary>
/// Gets or sets the fixed item source.
/// </summary>
[Parameter]
public ICollection<TItem>? Items { get; set; }
/// <inheritdoc />
protected override void OnParametersSet()
{
if (ItemSize <= 0)
{
throw new InvalidOperationException(
$"{GetType()} requires a positive value for parameter '{nameof(ItemSize)}' to perform virtualization.");
}
if (ItemsProvider != null)
{
if (Items != null)
{
throw new InvalidOperationException(
$"{GetType()} can only accept one item source from its parameters. " +
$"Do not supply both '{nameof(Items)}' and '{nameof(ItemsProvider)}'.");
}
_itemsProvider = ItemsProvider;
}
else if (Items != null)
{
_itemsProvider = DefaultItemsProvider;
}
else
{
throw new InvalidOperationException(
$"{GetType()} requires either the '{nameof(Items)}' or '{nameof(ItemsProvider)}' parameters to be specified " +
$"and non-null.");
}
_itemTemplate = ItemContent ?? ChildContent;
_placeholder = Placeholder ?? DefaultPlaceholder;
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsInterop = new VirtualizeJsInterop(this, JSRuntime);
await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter);
}
}
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (_refreshException != null)
{
var oldRefreshException = _refreshException;
_refreshException = null;
throw oldRefreshException;
}
builder.OpenElement(0, "div");
builder.AddAttribute(1, "style", GetSpacerStyle(_itemsBefore));
builder.AddElementReferenceCapture(2, elementReference => _spacerBefore = elementReference);
builder.CloseElement();
var lastItemIndex = Math.Min(_itemsBefore + _visibleItemCapacity, _itemCount);
var renderIndex = _itemsBefore;
var placeholdersBeforeCount = Math.Min(_loadedItemsStartIndex, lastItemIndex);
builder.OpenRegion(3);
// Render placeholders before the loaded items.
for (; renderIndex < placeholdersBeforeCount; renderIndex++)
{
// This is a rare case where it's valid for the sequence number to be programmatically incremented.
// This is only true because we know for certain that no other content will be alongside it.
builder.AddContent(renderIndex, _placeholder, new PlaceholderContext(renderIndex));
}
builder.CloseRegion();
// Render the loaded items.
if (_loadedItems != null && _itemTemplate != null)
{
var itemsToShow = _loadedItems
.Skip(_itemsBefore - _loadedItemsStartIndex)
.Take(lastItemIndex - _loadedItemsStartIndex);
builder.OpenRegion(4);
foreach (var item in itemsToShow)
{
_itemTemplate(item)(builder);
renderIndex++;
}
builder.CloseRegion();
}
builder.OpenRegion(5);
// Render the placeholders after the loaded items.
for (; renderIndex < lastItemIndex; renderIndex++)
{
builder.AddContent(renderIndex, _placeholder, new PlaceholderContext(renderIndex));
}
builder.CloseRegion();
var itemsAfter = Math.Max(0, _itemCount - _visibleItemCapacity - _itemsBefore);
builder.OpenElement(6, "div");
builder.AddAttribute(7, "style", GetSpacerStyle(itemsAfter));
builder.AddElementReferenceCapture(8, elementReference => _spacerAfter = elementReference);
builder.CloseElement();
}
private string GetSpacerStyle(int itemsInSpacer)
=> $"height: {itemsInSpacer * ItemSize}px;";
void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float containerSize)
{
CalcualteItemDistribution(spacerSize, containerSize, out var itemsBefore, out var visibleItemCapacity);
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
}
void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float containerSize)
{
CalcualteItemDistribution(spacerSize, containerSize, out var itemsAfter, out var visibleItemCapacity);
var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity);
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
}
private void CalcualteItemDistribution(float spacerSize, float containerSize, out int itemsInSpacer, out int visibleItemCapacity)
{
itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / ItemSize) - 1);
visibleItemCapacity = (int)Math.Ceiling(containerSize / ItemSize) + 2;
}
private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity)
{
if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity)
{
_itemsBefore = itemsBefore;
_visibleItemCapacity = visibleItemCapacity;
var refreshTask = RefreshDataAsync();
if (!refreshTask.IsCompleted)
{
StateHasChanged();
}
}
}
private async Task RefreshDataAsync()
{
_refreshCts?.Cancel();
_refreshCts = new CancellationTokenSource();
var cancellationToken = _refreshCts.Token;
var request = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken);
try
{
var result = await _itemsProvider(request);
// Only apply result if the task was not canceled.
if (!cancellationToken.IsCancellationRequested)
{
_itemCount = result.TotalItemCount;
_loadedItems = result.Items;
_loadedItemsStartIndex = request.StartIndex;
StateHasChanged();
}
}
catch (Exception e)
{
if (e is OperationCanceledException oce && oce.CancellationToken == cancellationToken)
{
// No-op; we canceled the operation, so it's fine to suppress this exception.
}
else
{
// Cache this exception so the renderer can throw it.
_refreshException = e;
// Re-render the component to throw the exception.
StateHasChanged();
}
}
}
private ValueTask<ItemsProviderResult<TItem>> DefaultItemsProvider(ItemsProviderRequest request)
{
return ValueTask.FromResult(new ItemsProviderResult<TItem>(
Items!.Skip(request.StartIndex).Take(request.Count),
Items!.Count));
}
private RenderFragment DefaultPlaceholder(PlaceholderContext context) => (builder) =>
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "style", $"height: {ItemSize}px;");
builder.CloseElement();
};
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_refreshCts?.Cancel();
if (_jsInterop != null)
{
await _jsInterop.DisposeAsync();
}
}
}
}

View File

@ -0,0 +1,52 @@
// 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;
using System.Threading.Tasks;
using Microsoft.JSInterop;
namespace Microsoft.AspNetCore.Components.Web.Virtualization
{
internal class VirtualizeJsInterop : IAsyncDisposable
{
private const string JsFunctionsPrefix = "Blazor._internal.Virtualize";
private readonly IVirtualizeJsCallbacks _owner;
private readonly IJSRuntime _jsRuntime;
private DotNetObjectReference<VirtualizeJsInterop>? _selfReference;
public VirtualizeJsInterop(IVirtualizeJsCallbacks owner, IJSRuntime jsRuntime)
{
_owner = owner;
_jsRuntime = jsRuntime;
}
public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter)
{
_selfReference = DotNetObjectReference.Create(this);
await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter);
}
[JSInvokable]
public void OnSpacerBeforeVisible(float spacerSize, float containerSize)
{
_owner.OnBeforeSpacerVisible(spacerSize, containerSize);
}
[JSInvokable]
public void OnSpacerAfterVisible(float spacerSize, float containerSize)
{
_owner.OnAfterSpacerVisible(spacerSize, containerSize);
}
public async ValueTask DisposeAsync()
{
if (_selfReference != null)
{
await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.dispose", _selfReference);
}
}
}
}

View File

@ -0,0 +1,146 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Microsoft.AspNetCore.Components.Web.Virtualization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Virtualization
{
public class VirtualizeTest
{
[Fact]
public async Task Virtualize_ThrowsWhenGivenNonPositiveItemSize()
{
var rootComponent = new VirtualizeTestHostcomponent
{
InnerContent = BuildVirtualize(0f, EmptyItemsProvider<int>, null)
};
var serviceProvider = new ServiceCollection()
.AddTransient((sp) => Mock.Of<IJSRuntime>())
.BuildServiceProvider();
var testRenderer = new TestRenderer(serviceProvider);
var componentId = testRenderer.AssignRootComponentId(rootComponent);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
Assert.Contains("requires a positive value for parameter", ex.Message);
}
[Fact]
public async Task Virtualize_ThrowsWhenGivenMultipleItemSources()
{
var rootComponent = new VirtualizeTestHostcomponent
{
InnerContent = BuildVirtualize(10f, EmptyItemsProvider<int>, new List<int>())
};
var serviceProvider = new ServiceCollection()
.AddTransient((sp) => Mock.Of<IJSRuntime>())
.BuildServiceProvider();
var testRenderer = new TestRenderer(serviceProvider);
var componentId = testRenderer.AssignRootComponentId(rootComponent);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
Assert.Contains("can only accept one item source from its parameters", ex.Message);
}
[Fact]
public async Task Virtualize_ThrowsWhenGivenNoItemSources()
{
var rootComponent = new VirtualizeTestHostcomponent
{
InnerContent = BuildVirtualize<int>(10f, null, null)
};
var serviceProvider = new ServiceCollection()
.AddTransient((sp) => Mock.Of<IJSRuntime>())
.BuildServiceProvider();
var testRenderer = new TestRenderer(serviceProvider);
var componentId = testRenderer.AssignRootComponentId(rootComponent);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
Assert.Contains("parameters to be specified and non-null", ex.Message);
}
[Fact]
public async Task Virtualize_DispatchesExceptionsFromItemsProviderThroughRenderer()
{
Virtualize<int> renderedVirtualize = null;
var rootComponent = new VirtualizeTestHostcomponent
{
InnerContent = BuildVirtualize(10f, AlwaysThrowsItemsProvider<int>, null, virtualize => renderedVirtualize = virtualize)
};
var serviceProvider = new ServiceCollection()
.AddTransient((sp) => Mock.Of<IJSRuntime>())
.BuildServiceProvider();
var testRenderer = new TestRenderer(serviceProvider);
var componentId = testRenderer.AssignRootComponentId(rootComponent);
// Render to populate the component reference.
await testRenderer.RenderRootComponentAsync(componentId);
Assert.NotNull(renderedVirtualize);
// Simulate a JS spacer callback.
((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(10f, 100f);
// Validate that the exception is dispatched through the renderer.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
Assert.Equal("Thrown from items provider.", ex.Message);
}
private ValueTask<ItemsProviderResult<TItem>> EmptyItemsProvider<TItem>(ItemsProviderRequest request)
=> ValueTask.FromResult(new ItemsProviderResult<TItem>(Enumerable.Empty<TItem>(), 0));
private ValueTask<ItemsProviderResult<TItem>> AlwaysThrowsItemsProvider<TItem>(ItemsProviderRequest request)
=> throw new InvalidOperationException("Thrown from items provider.");
private RenderFragment BuildVirtualize<TItem>(
float itemSize,
ItemsProviderDelegate<TItem> itemsProvider,
ICollection<TItem> items,
Action<Virtualize<TItem>> captureRenderedVirtualize = null)
=> builder =>
{
builder.OpenComponent<Virtualize<TItem>>(0);
builder.AddAttribute(1, "ItemSize", itemSize);
builder.AddAttribute(2, "ItemsProvider", itemsProvider);
builder.AddAttribute(3, "Items", items);
if (captureRenderedVirtualize != null)
{
builder.AddComponentReferenceCapture(4, component => captureRenderedVirtualize(component as Virtualize<TItem>));
}
builder.CloseComponent();
};
private class VirtualizeTestHostcomponent : AutoRenderComponent
{
public RenderFragment InnerContent { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "style", "overflow: auto; height: 800px;");
builder.AddContent(2, InnerContent);
builder.CloseElement();
}
}
}
}

View File

@ -0,0 +1,207 @@
// 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 BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.Extensions;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETests.Tests
{
public class VirtualizationTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
{
public VirtualizationTest(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}
protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client);
Browser.MountTestComponent<VirtualizationComponent>();
}
[Fact]
public void AlwaysFillsVisibleCapacity_Sync()
{
var topSpacer = Browser.FindElement(By.Id("sync-container")).FindElement(By.TagName("div"));
var expectedInitialSpacerStyle = "height: 0px;";
int initialItemCount = 0;
// Wait until items have been rendered.
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
Browser.Equal(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
// Scroll halfway.
Browser.ExecuteJavaScript("const container = document.getElementById('sync-container');container.scrollTop = container.scrollHeight * 0.5;");
// Validate that we get the same item count after scrolling halfway.
Browser.Equal(initialItemCount, GetItemCount);
Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
// Scroll to the bottom.
Browser.ExecuteJavaScript("const container = document.getElementById('sync-container');container.scrollTop = container.scrollHeight;");
// Validate that we get the same item count after scrolling to the bottom.
Browser.Equal(initialItemCount, GetItemCount);
Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
int GetItemCount() => Browser.FindElements(By.Id("sync-item")).Count;
}
[Fact]
public void AlwaysFillsVisibleCapacity_Async()
{
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
// Check that no items or placeholders are visible.
// No data fetches have happened so we don't know how many items there are.
Browser.Equal(0, GetItemCount);
Browser.Equal(0, GetPlaceholderCount);
// Load the initial set of items.
finishLoadingButton.Click();
var initialItemCount = 0;
// Validate that items appear and placeholders aren't rendered.
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
Browser.Equal(0, GetPlaceholderCount);
// Scroll halfway.
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = container.scrollHeight * 0.5;");
// Validate that items are replaced by the same number of placeholders.
Browser.Equal(0, GetItemCount);
Browser.Equal(initialItemCount, GetPlaceholderCount);
// Load the new set of items.
finishLoadingButton.Click();
// Validate that the placeholders are replaced by the same number of items.
Browser.Equal(initialItemCount, GetItemCount);
Browser.Equal(0, GetPlaceholderCount);
// Scroll to the bottom.
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = container.scrollHeight;");
// Validate that items are replaced by the same number of placeholders.
Browser.Equal(0, GetItemCount);
Browser.Equal(initialItemCount, GetPlaceholderCount);
// Load the new set of items.
finishLoadingButton.Click();
// Validate that the placeholders are replaced by the same number of items.
Browser.Equal(initialItemCount, GetItemCount);
Browser.Equal(0, GetPlaceholderCount);
int GetItemCount() => Browser.FindElements(By.Id("async-item")).Count;
int GetPlaceholderCount() => Browser.FindElements(By.Id("async-placeholder")).Count;
}
[Fact]
public void RerendersWhenItemSizeShrinks_Sync()
{
int initialItemCount = 0;
// Wait until items have been rendered.
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
var itemSizeInput = Browser.FindElement(By.Id("item-size-input"));
// Change the item size.
itemSizeInput.SendKeys("\b\b\b50\n");
// Validate that the list has been re-rendered to show more items.
Browser.True(() => GetItemCount() > initialItemCount);
int GetItemCount() => Browser.FindElements(By.Id("sync-item")).Count;
}
[Fact]
public void RerendersWhenItemSizeShrinks_Async()
{
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
// Load the initial set of items.
finishLoadingButton.Click();
int initialItemCount = 0;
// Validate that items appear and placeholders aren't rendered.
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
Browser.Equal(0, GetPlaceholderCount);
var itemSizeInput = Browser.FindElement(By.Id("item-size-input"));
// Change the item size.
itemSizeInput.SendKeys("\b\b\b50\n");
// Validate that the same number of loaded items is rendered.
Browser.Equal(initialItemCount, GetItemCount);
Browser.True(() => GetPlaceholderCount() > 0);
// Load the new set of items.
finishLoadingButton.Click();
// Validate that the placeholders have been replaced with more loaded items.
Browser.True(() => GetItemCount() > initialItemCount);
Browser.Equal(0, GetPlaceholderCount);
int GetItemCount() => Browser.FindElements(By.Id("async-item")).Count;
int GetPlaceholderCount() => Browser.FindElements(By.Id("async-placeholder")).Count;
}
[Fact]
public void CancelsOutdatedRefreshes_Async()
{
var cancellationCount = Browser.FindElement(By.Id("cancellation-count"));
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
// Load the initial set of items.
finishLoadingButton.Click();
// Validate that there are no initial cancellations.
Browser.Equal("0", () => cancellationCount.Text);
// Validate that there is no initial fetch to cancel.
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = 1000;");
Browser.Equal("0", () => cancellationCount.Text);
// Validate that scrolling again cancels the first fetch.
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = 2000;");
Browser.Equal("1", () => cancellationCount.Text);
// Validate that scrolling again cancels the second fetch.
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = 3000;");
Browser.Equal("2", () => cancellationCount.Text);
}
[Fact]
public void CanUseViewportAsContainer()
{
var expectedInitialSpacerStyle = "height: 0px;";
var topSpacer = Browser.FindElement(By.Id("viewport-as-root")).FindElement(By.TagName("div"));
Browser.ExecuteJavaScript("const element = document.getElementById('viewport-as-root'); element.scrollIntoView();");
// Validate that the top spacer has a height of zero.
Browser.Equal(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
Browser.ExecuteJavaScript("window.scrollTo(0, document.body.scrollHeight);");
// Validate that the top spacer has expanded.
Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
}
}
}

View File

@ -83,6 +83,7 @@
<option value="BasicTestApp.SvgWithChildComponent">SVG with child component</option>
<option value="BasicTestApp.TextOnlyComponent">Plain text</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>
</select>

View File

@ -0,0 +1,76 @@
<p>
Item size:<br />
<input id="item-size-input" type="number" @bind-value="itemSize" />
</p>
<p>
Synchronous:<br />
<div id="sync-container" style="background-color: #eee; height: 500px; overflow-y: auto">
<Virtualize Items="@fixedItems" ItemSize="itemSize">
<div @key="context" id="sync-item" style="height: @(itemSize)px; background-color: rgb(@((context % 2) * 255), @((1-(context % 2)) * 255), 255);">Item @context</div>
</Virtualize>
</div>
</p>
<p>
Asynchronous:<br />
<button id="finish-loading-button" @onclick="FinishLoadingAsync">Finish loading</button><br />
Cancellation count: <span id="cancellation-count">@asyncCancellationCount</span><br />
<div id="async-container" style="background-color: #eee; height: 500px; overflow-y: auto">
<Virtualize ItemsProvider="GetItemsAsync" ItemSize="itemSize">
<ItemContent>
<div @key="context" id="async-item" style="height: @(itemSize)px; background-color: rgb(@((context % 2) * 255), @((1-(context % 2)) * 255), 255);">Item @context</div>
</ItemContent>
<Placeholder>
<div id="async-placeholder" style="height: @(itemSize)px; background-color: orange;">Loading item @context.Index...</div>
</Placeholder>
</Virtualize>
</div>
</p>
<p id="viewport-as-root">
Viewport as root:<br />
<Virtualize Items="@fixedItems" ItemSize="itemSize">
<div @key="context" style="height: @(itemSize)px; background-color: rgb(@((context % 2) * 255), @((1-(context % 2)) * 255), 255);">Item @context</div>
</Virtualize>
</p>
@code {
float itemSize = 100;
ICollection<int> fixedItems = Enumerable.Range(0, 1000).ToList();
int asyncTotalItemCount = 200;
int asyncCancellationCount = 0;
TaskCompletionSource asyncTcs = new TaskCompletionSource();
HashSet<int> cachedItems = new HashSet<int>();
async ValueTask<ItemsProviderResult<int>> GetItemsAsync(ItemsProviderRequest request)
{
var loadingTask = asyncTcs.Task;
var registration = request.CancellationToken.Register(() => CancelLoadingAsync(request.CancellationToken));
await loadingTask;
registration.Dispose();
return new ItemsProviderResult<int>(Enumerable.Range(request.StartIndex, request.Count), asyncTotalItemCount);
}
void FinishLoadingAsync()
{
asyncTcs.SetResult();
asyncTcs = new TaskCompletionSource();
}
void CancelLoadingAsync(System.Threading.CancellationToken cancellationToken)
{
asyncTcs.TrySetCanceled(cancellationToken);
asyncTcs = new TaskCompletionSource();
asyncCancellationCount++;
StateHasChanged();
}
}

View File

@ -1 +1,2 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization

View File

@ -4,6 +4,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BlazorServerWeb_CSharp
@using BlazorServerWeb_CSharp.Shared

View File

@ -6,6 +6,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@*#if (!Hosted)