Skip to content
Merged
Show file tree
Hide file tree
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
54 changes: 48 additions & 6 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const SpacerVisibilityReason = {
RenderedContentMeasurement: 3,
} as const;

const ViewportFillDirection = {
Covered: 0,
Before: 1,
After: 2,
} as const;

const ScrollSource = {
None: 0,
UserScroll: 1,
Expand Down Expand Up @@ -644,7 +650,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
}

// Measures the target's viewport-relative top and aligns it to containerTop.
function alignToItemAt(localIndex: number): void {
function alignToItemAt(localIndex: number): number | null {
function beginAlign(): void {
scrollActivity.ignoreNextScroll();
scrollActivity.source = ScrollSource.AlignToItem;
Expand All @@ -656,10 +662,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
flushPendingStyleMutations();
const delta = measureLocalChildOffset(localIndex);
if (Number.isNaN(delta)) {
// Target item isn't in DOM yet. Retry after the next render.
// Target item isn't in the committed window.
pendingAlignLocalIndex = localIndex;
beginAlign();
return;
return null;
}
pendingAlignLocalIndex = null;

Expand All @@ -671,6 +677,36 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
pendingJumpToEnd = false;
scrollElement.scrollTo({ top: scrollElement.scrollTop + delta, behavior: 'instant' });
}

return getViewportFillDirection();
}

function getViewportBounds(scaleFactor: number): { top: number; bottom: number } {
let viewportTop = 0;
let viewportBottom = document.documentElement.clientHeight;
if (scrollContainer) {
const scrollContainerRect = scrollContainer.getBoundingClientRect();
viewportTop = scrollContainerRect.top + scrollContainer.clientTop * scaleFactor;
viewportBottom = viewportTop + scrollContainer.clientHeight * scaleFactor;
}
return { top: viewportTop, bottom: viewportBottom };
}

function occupiesViewport(spacer: HTMLElement, viewport: { top: number; bottom: number }): boolean {
const spacerRect = spacer.getBoundingClientRect();
return Math.min(spacerRect.bottom, viewport.bottom) > Math.max(spacerRect.top, viewport.top);
}

function getViewportFillDirection(): number {
const scaleFactor = getScaleFactor(spacerBefore, spacerAfter);
const viewport = getViewportBounds(scaleFactor);
if (occupiesViewport(spacerBefore, viewport)) {
return ViewportFillDirection.Before;
}
if (occupiesViewport(spacerAfter, viewport)) {
return ViewportFillDirection.After;
}
return ViewportFillDirection.Covered;
}

observersByDotNetObjectId[id] = {
Expand Down Expand Up @@ -855,6 +891,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
});

if (intersectingEntries.length === 0) {
if (source === ScrollSource.AlignToItem) {
scrollActivity.clear();
}
return;
}

Expand All @@ -875,7 +914,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
const isBefore = entry.target === spacerBefore;
const spacer = isBefore ? spacerBefore : spacerAfter;

// Skip an empty after spacer because it provides no useful measurement.
if (!isBefore && spacer.offsetHeight === 0) {
return;
}
Expand All @@ -895,6 +933,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
const methodName = isBefore ? 'OnSpacerBeforeVisible' : 'OnSpacerAfterVisible';
dotNetHelper.invokeMethodAsync(methodName, spacerSize, spacerSeparation, containerSize, reason);
});

if (source === ScrollSource.AlignToItem) {
scrollActivity.clear();
}
}

function isValidTableElement(element: HTMLElement | null): boolean {
Expand Down Expand Up @@ -934,9 +976,9 @@ function restoreAnchor(dotNetHelper: DotNet.DotNetObject): void {
entry?.restoreAnchor?.();
}

function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): void {
function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): number | null {
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
observersByDotNetObjectId[id]?.alignToItem?.(localIndex);
return observersByDotNetObjectId[id]?.alignToItem?.(localIndex) ?? null;
}

function beginProgrammaticScroll(dotNetHelper: DotNet.DotNetObject): void {
Expand Down
15 changes: 15 additions & 0 deletions src/Components/Web/src/Virtualization/ViewportFillDirection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Web.Virtualization;

/// <remarks>
/// The numeric values must stay in sync with the <c>ViewportFillDirection</c> constant in
/// <c>Virtualize.ts</c>.
/// </remarks>
internal enum ViewportFillDirection
{
Covered = 0,
Before = 1,
After = 2,
}
144 changes: 102 additions & 42 deletions src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ private async Task ScrollToItemAsyncCore(int itemIndex, CancellationToken cancel
var ourCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_currentScrollCts = ourCts;
var token = ourCts.Token;
ViewportFillDirection? fillDirection = null;

if (_jsInterop is not null)
{
Expand All @@ -280,7 +281,7 @@ private async Task ScrollToItemAsyncCore(int itemIndex, CancellationToken cancel
token.ThrowIfCancellationRequested();
var refetchRequired = MoveWindowToContain(itemIndex);
await EnsureRenderCommittedAsync(refetchRequired, token);
await AlignToTargetAsync(itemIndex, token);
fillDirection = await AlignToTargetAsync(itemIndex, token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
Expand All @@ -295,6 +296,8 @@ private async Task ScrollToItemAsyncCore(int itemIndex, CancellationToken cancel
}
ourCts.Dispose();
}

UpdateWindowFromViewport(fillDirection, _visibleItemCapacity, _unusedItemCapacity);
}

private bool MoveWindowToContain(int itemIndex)
Expand Down Expand Up @@ -346,21 +349,30 @@ private async Task EnsureRenderCommittedAsync(bool refetchRequired, Cancellation
token.ThrowIfCancellationRequested();
}

private async ValueTask AlignToTargetAsync(int itemIndex, CancellationToken token)
private async ValueTask<ViewportFillDirection?> AlignToTargetAsync(int itemIndex, CancellationToken token)
{
// Re-clamp in case _itemCount shifted during the fetch.
var localIndex = ClampToItemRange(itemIndex) - _itemsBefore;
if (localIndex < 0 || localIndex >= _visibleItemCapacity || _lastRenderedItemCount == 0)
{
// Window doesn't contain the target (e.g., empty provider result) — bail cleanly.
return;
return null;
}

// Pixel-exact one-shot scroll: JS reads getBoundingClientRect() and sets scrollTop.
if (_jsInterop is not null)
if (_jsInterop is null)
{
await _jsInterop.AlignToItemAsync(localIndex, token);
return null;
}

var initialItemSize = _itemSize;
var fillDirection = await _jsInterop.AlignToItemAsync(localIndex, token);
if (_initialIndex.Phase == InitialIndexPhase.Pending && _itemSize != initialItemSize)
{
StateHasChanged();
return null;
}

return fillDirection;
}

private int ClampToItemRange(int requested)
Expand Down Expand Up @@ -516,9 +528,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
}
}

if (_jsInterop is not null && _lastRenderedItemCount > 0 && _initialIndex.ShouldRealign(_itemSize))
if (_jsInterop is not null
&& !_loading
&& _loadedItemsStartIndex == _itemsBefore
&& _lastRenderedItemCount > 0
&& _lastRenderedPlaceholderCount == 0
&& _initialIndex.Phase == InitialIndexPhase.Pending)
{
await AlignToTargetAsync(InitialItemIndex, CancellationToken.None);
var fillDirection = await AlignToTargetAsync(InitialItemIndex, CancellationToken.None);
UpdateWindowFromViewport(fillDirection, _visibleItemCapacity, _unusedItemCapacity);
}
}

Expand Down Expand Up @@ -626,17 +644,7 @@ private void UpdateItemSizeFromRenderedContent(float spacerSize, float spacerSep
return;
}

var previousItemSize = _itemSize;
CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out _, out _, out _);
RerenderSpacersIfItemSizeChanged(previousItemSize);
}

private void RerenderSpacersIfItemSizeChanged(float previousItemSize)
{
if (_itemSize != previousItemSize)
{
StateHasChanged();
}
}

private void CancelInFlightScrollForUserInteraction()
Expand Down Expand Up @@ -673,9 +681,9 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer
CancelInFlightScrollForUserInteraction();
break;
case SpacerVisibilityReason.ViewportFill:
// A fill callback while our own scroll is in flight, or while the initial target is pinned,
// is a side effect of that scroll — acting on it would move the target.
if (_currentScrollCts is not null || _initialIndex.Phase == InitialIndexPhase.Pending)
// A fill callback while our own scroll is in flight is a side effect of that scroll —
// acting on it would move the target.
if (_currentScrollCts is not null)
{
return;
}
Expand All @@ -684,6 +692,15 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer

CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity);

if (_initialIndex.Phase == InitialIndexPhase.Pending)
{
UpdateWindowFromViewport(
ViewportFillDirection.Before,
visibleItemCapacity,
unusedItemCapacity);
return;
}

// Slide window up by at least one if spacer is visible but position unchanged.
if (_lastRenderedItemCount > 0 && itemsBefore == _itemsBefore && itemsBefore > 0)
{
Expand Down Expand Up @@ -714,11 +731,14 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS
// landed, so acting on it would undo the target. The real fill runs once the scroll completes.
return;
}

var hadNewMeasurements = CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity);

if (_initialIndex.Phase == InitialIndexPhase.Pending)
{
UpdateWindowFromViewport(
ViewportFillDirection.After,
visibleItemCapacity,
unusedItemCapacity);
return;
}

Expand All @@ -744,6 +764,51 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS
UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
}

private void UpdateWindowFromViewport(
ViewportFillDirection? fillDirection,
int visibleItemCapacity,
int unusedItemCapacity)
{
if (fillDirection == ViewportFillDirection.Covered)
{
if (_initialIndex.Phase == InitialIndexPhase.Pending && _lastRenderedPlaceholderCount == 0)
{
_initialIndex.Complete();
}
return;
}

if (fillDirection is null)
{
return;
}

var maximumCapacity = Math.Min(GetMaximumItemCapacity(), _itemCount);
var doubledCapacity = (long)Math.Max(1, _visibleItemCapacity) * 2;
var desiredCapacity = (int)Math.Min(
Math.Max((long)visibleItemCapacity, doubledCapacity),
maximumCapacity);
var availableItems = fillDirection == ViewportFillDirection.Before
? _itemsBefore
: Math.Max(0, _itemCount - _itemsBefore - _visibleItemCapacity);
var addedItems = Math.Min(
Math.Max(0, desiredCapacity - _visibleItemCapacity),
availableItems);

if (addedItems > 0 || unusedItemCapacity != _unusedItemCapacity)
{
_skipNextDistributionRefresh = false;
UpdateItemDistribution(
fillDirection == ViewportFillDirection.Before ? _itemsBefore - addedItems : _itemsBefore,
_visibleItemCapacity + addedItems,
unusedItemCapacity);
}
else if (_initialIndex.Phase == InitialIndexPhase.Pending && !_loading)
{
_initialIndex.Complete();
}
}

private float GetEffectiveItemSizeForStaleSpacer()
{
var effectiveItemSize = GetItemHeight();
Expand Down Expand Up @@ -801,6 +866,18 @@ private bool CalculateItemDistribution(
// 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 = GetMaximumItemCapacity();

itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / effectiveItemSize) - OverscanCount);
visibleItemCapacity = (int)Math.Ceiling(containerSize / effectiveItemSize) + 2 * OverscanCount;
unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount);
visibleItemCapacity -= unusedItemCapacity;

return hadNewMeasurements;
}

private int GetMaximumItemCapacity()
{
var maxItemCount = AppContext.GetData("Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.MaxItemCount") switch
{
int val => Math.Min(val, MaxItemCount),
Expand All @@ -809,14 +886,7 @@ private bool CalculateItemDistribution(

// Count the OverscanCount as used capacity, so we don't end up in a situation where
// the user has set a very low MaxItemCount and we end up in an infinite loading loop.
maxItemCount += OverscanCount * 2;

itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / effectiveItemSize) - OverscanCount);
visibleItemCapacity = (int)Math.Ceiling(containerSize / effectiveItemSize) + 2 * OverscanCount;
unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount);
visibleItemCapacity -= unusedItemCapacity;

return hadNewMeasurements;
return (int)Math.Min((long)maxItemCount + (long)OverscanCount * 2, int.MaxValue);
}

private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity, int unusedItemCapacity)
Expand Down Expand Up @@ -1119,10 +1189,10 @@ private enum InitialIndexPhase

private sealed class InitialIndexState
{
public InitialIndexPhase Phase { get; private set; }

private float _alignItemSize;

public InitialIndexPhase Phase { get; private set; }

public void Complete() => Phase = InitialIndexPhase.Completed;

public void BeginPending(float itemSize)
Expand All @@ -1131,16 +1201,6 @@ public void BeginPending(float itemSize)
_alignItemSize = itemSize;
}

public bool ShouldRealign(float itemSize)
{
if (Phase != InitialIndexPhase.Pending || itemSize == _alignItemSize)
{
return false;
}
_alignItemSize = itemSize;
return true;
}

public void Abort()
{
if (Phase == InitialIndexPhase.Pending)
Expand Down
Loading
Loading