diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 703628420ce3..fff6e6e7ba1e 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -10,6 +10,8 @@ export const Virtualize = { refreshObservers, setAnchorMode, restoreAnchor, + alignToItem, + beginProgrammaticScroll, }; const dispatcherObserversByDotNetIdPropname = Symbol(); @@ -110,6 +112,15 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectionObserver.observe(spacerAfter); } + // Called by C# at the start of a programmatic ScrollToIndex. Suppresses spacer-IO + // callbacks (which would otherwise be misinterpreted as a "user scroll") until + // either alignToItemAt completes or a real user scroll fires. + function beginProgrammaticScrollSuppression(): void { + suppressSpacerCallbacks = true; + pendingCallbacks.delete(spacerBefore); + pendingCallbacks.delete(spacerAfter); + } + function getObservedHeight(entry: ResizeObserverEntry): number { return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; } @@ -292,6 +303,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return; } + // Retry a pending programmatic alignment now that items may be in DOM. + if (pendingAlignLocalIndex !== null) { + const pending = pendingAlignLocalIndex; + pendingAlignLocalIndex = null; + alignToItemAt(pending); + return; + } + // Beginning mode at the very top: show new items by converging to top. if ((anchorMode & 1) && snapshot.scrollTop < 1) { convergingToTop = true; @@ -300,19 +319,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return; } - let current = spacerBefore.nextElementSibling; - for (let i = 0; i < snapshot.anchorItemIndex && current && current !== spacerAfter; i++) { - current = current.nextElementSibling; - } - - if (!current || current === spacerAfter) { + const newOffset = measureLocalChildOffset(snapshot.anchorItemIndex); + if (Number.isNaN(newOffset)) { return; } - - const containerTop = scrollContainer - ? scrollContainer.getBoundingClientRect().top - : 0; - const newOffset = current.getBoundingClientRect().top - containerTop; const delta = newOffset - snapshot.anchorOffset; // Suppress spacer IO until next user scroll. Save anchor for drift correction. @@ -335,8 +345,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // Save anchor offset AFTER scrollTop adjustment for drift correction. if (pendingScrollCorrection) { - const containerTop = scrollContainer ? scrollContainer.getBoundingClientRect().top : 0; - scrollCorrectionOffset = current.getBoundingClientRect().top - containerTop; + const correctedOffset = measureLocalChildOffset(snapshot.anchorItemIndex); + if (!Number.isNaN(correctedOffset)) { + scrollCorrectionOffset = correctedOffset; + } } if (preserveWasAtBottom) { @@ -426,6 +438,58 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); let pendingCallbacks: Map = new Map(); let callbackTimeout: ReturnType | null = null; + let pendingAlignLocalIndex: number | null = null; + + // Walks `localIndex` siblings forward from spacerBefore to find the rendered child, + // returning its viewport-relative top measured against the scroll container (or 0 for + // the window-scroll case). Returns NaN when the slot is missing — e.g., the row hasn't + // rendered yet, or the local index falls outside the currently rendered window. + function measureLocalChildOffset(localIndex: number): number { + let el: Element | null = spacerBefore.nextElementSibling; + for (let i = 0; i < localIndex && el && el !== spacerAfter; i++) { + el = el.nextElementSibling; + } + if (!el || el === spacerAfter) { + return Number.NaN; + } + const containerTop = scrollElement === document.documentElement + ? 0 + : scrollElement.getBoundingClientRect().top; + return el.getBoundingClientRect().top - containerTop; + } + + // Measures the target's viewport-relative top and aligns it to containerTop. + function alignToItemAt(localIndex: number): void { + const delta = measureLocalChildOffset(localIndex); + if (Number.isNaN(delta)) { + // Items aren't in DOM yet. Retry after the next render commit. + pendingAlignLocalIndex = localIndex; + ignoreAnchorScroll = true; + suppressSpacerCallbacks = true; + observersByDotNetObjectId[id].anchorSnapshot = null; + if (convergingToTop || convergingToBottom) { + convergingToTop = false; + convergingToBottom = false; + stopConvergenceObserving(); + } + return; + } + pendingAlignLocalIndex = null; + if (Math.abs(delta) > 0.5) { + ignoreAnchorScroll = true; + suppressSpacerCallbacks = true; + // Programmatic scroll establishes a new explicit position — invalidate any pending anchor snapshot and cancel in-progress convergence. + observersByDotNetObjectId[id].anchorSnapshot = null; + if (convergingToTop || convergingToBottom) { + convergingToTop = false; + convergingToBottom = false; + stopConvergenceObserving(); + } + pendingJumpToStart = false; + pendingJumpToEnd = false; + scrollElement.scrollTo({ top: scrollElement.scrollTop + delta, behavior: 'instant' }); + } + } observersByDotNetObjectId[id] = { intersectionObserver, @@ -436,6 +500,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac setConvergingToBottom: () => { convergingToBottom = true; }, setAnchorMode: (mode: number) => { anchorMode = mode; }, restoreAnchor: restoreAnchorForShift, + alignToItem: alignToItemAt, + beginProgrammaticScroll: beginProgrammaticScrollSuppression, anchorSnapshot: null as { anchorItemIndex: number; anchorOffset: number; scrollTop: number } | null, onDispose: () => { stopConvergenceObserving(); @@ -659,6 +725,16 @@ function restoreAnchor(dotNetHelper: DotNet.DotNetObject): void { entry?.restoreAnchor?.(); } +function alignToItem(dotNetHelper: DotNet.DotNetObject, localIndex: number): void { + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + observersByDotNetObjectId[id]?.alignToItem?.(localIndex); +} + +function beginProgrammaticScroll(dotNetHelper: DotNet.DotNetObject): void { + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + observersByDotNetObjectId[id]?.beginProgrammaticScroll?.(); +} + function getObserversMapEntry(dotNetHelper: DotNet.DotNetObject): { observersByDotNetObjectId: {[id: number]: any }, id: number } { const dotNetHelperDispatcher = dotNetHelper['_callDispatcher']; const dotNetHelperId = dotNetHelper['_id']; diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index de81db69b128..49831c06ffca 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -86,8 +86,11 @@ Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute.Name.set Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute.SupplyParameterFromSessionAttribute() -> void Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.AnchorMode.get -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.AnchorMode.set -> void +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.InitialIndex.get -> int +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.InitialIndex.set -> void Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.ItemComparer.get -> System.Collections.Generic.IEqualityComparer! Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.ItemComparer.set -> void +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.ScrollToIndexAsync(int itemIndex, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.Beginning = 1 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.End = 2 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 37d9cbd93ba0..93d8f759e5cd 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -53,6 +53,14 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private CancellationTokenSource? _refreshCts; + private CancellationTokenSource? _currentScrollCts; + + private bool _inFlightScrollHasRendered; + + private TaskCompletionSource? _nextRenderTcs; + + private bool _initialScrollApplied; + private bool _skipNextDistributionRefresh; private Exception? _refreshException; @@ -193,6 +201,15 @@ public IEqualityComparer ItemComparer } } + /// + /// Gets or sets the zero-based index of the item to scroll to on first interactive render. + /// Applied once when the component first knows its item count and ignored on subsequent re-renders; + /// to scroll programmatically at any later point, call . + /// Out-of-range values are clamped. The default value, 0, means no initial scroll. + /// + [Parameter] + public int InitialIndex { get; set; } + private IEqualityComparer _itemComparer = EqualityComparer.Default; /// @@ -211,6 +228,157 @@ public async Task RefreshDataAsync() await RefreshDataCoreAsync(renderOnSuccess: false); } + /// + /// Scrolls the viewport so the item at is aligned to the start of the visible area. + /// + /// + /// Each call cancels any previously-running (last call wins). + /// Must be called on the renderer's synchronization context; background-thread callers should wrap with + /// to await completion. + /// + /// The zero-based index of the item to scroll to. + /// A token that lets the caller request cancellation. + /// A that completes when the target is aligned or superseded by another call, + /// or faults with if is cancelled. + public Task ScrollToIndexAsync(int itemIndex, CancellationToken cancellationToken = default) + { + if (_jsInterop is null) + { + // Throw synchronously so misuse is reported on the call site, not on the Task. + throw new InvalidOperationException( + $"{nameof(ScrollToIndexAsync)} cannot be called before the {nameof(Virtualize)} has been initialized for interactive rendering. " + + $"Use the {nameof(InitialIndex)} parameter to set the initial scroll position."); + } + + return ScrollToIndexAsyncCore(itemIndex, cancellationToken); + } + + private async Task ScrollToIndexAsyncCore(int itemIndex, CancellationToken cancellationToken) + { + // Cancel-and-switch (last call wins); finally block guards cleanup by ref-equality. + _currentScrollCts?.Cancel(); + + var ourCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _currentScrollCts = ourCts; + _inFlightScrollHasRendered = false; + var token = ourCts.Token; + + // Suppress JS spacer-IO callbacks until alignToItem completes or a real user scrolls. + if (_jsInterop is not null) + { + try + { + await _jsInterop.BeginProgrammaticScrollAsync(); + } + catch (OperationCanceledException) { } + catch (JSDisconnectedException) { } + } + + try + { + token.ThrowIfCancellationRequested(); + var refetchRequired = MoveWindowToContain(itemIndex); + await EnsureRenderCommittedAsync(refetchRequired, token); + await AlignToTargetAsync(itemIndex, token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Ignore exceptions caused by cancellations. + } + finally + { + // Only the current owner clears shared state; orphaned operations leave it alone. + if (ReferenceEquals(_currentScrollCts, ourCts)) + { + _currentScrollCts = null; + } + ourCts.Dispose(); + } + } + + private bool MoveWindowToContain(int itemIndex) + { + var clamped = ClampToItemRange(itemIndex); + var capacity = _visibleItemCapacity > 0 ? _visibleItemCapacity : OverscanCount * 2 + 1; + var desiredItemsBefore = Math.Max(0, clamped - OverscanCount); + if (_itemCount > 0 && desiredItemsBefore + capacity > _itemCount) + { + desiredItemsBefore = Math.Max(0, _itemCount - capacity); + } + + var windowChanged = desiredItemsBefore != _itemsBefore; + if (_visibleItemCapacity <= 0) + { + // Seed capacity when called before any spacer-observer feedback so RefreshDataCoreAsync asks for a meaningful slice. + _visibleItemCapacity = capacity; + } + if (windowChanged) + { + _itemsBefore = desiredItemsBefore; + _skipNextDistributionRefresh = false; + } + + var alreadyLoadedForWindow = _loadedItems is not null && _loadedItemsStartIndex == _itemsBefore; + return windowChanged || !alreadyLoadedForWindow; + } + + private async Task EnsureRenderCommittedAsync(bool refetchRequired, CancellationToken token) + { + // Set up the signal BEFORE any render: on WASM, OnAfterRenderAsync runs synchronously inside RefreshDataCoreAsync. + var renderCommitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _nextRenderTcs = renderCommitTcs; + using var renderReg = token.Register(static state => ((TaskCompletionSource)state!).TrySetCanceled(), renderCommitTcs); + + if (refetchRequired) + { + await RefreshDataCoreAsync(renderOnSuccess: true); + token.ThrowIfCancellationRequested(); + } + else + { + // Window already loaded — trigger one render so we can wait for the DOM to reflect it. + StateHasChanged(); + } + + // OnAfterRenderAsync signals once the rendered slice matches our window, so one await is enough. + await renderCommitTcs.Task; + token.ThrowIfCancellationRequested(); + } + + private async ValueTask 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; + } + + // Pixel-exact one-shot scroll: JS reads getBoundingClientRect() and sets scrollTop. + if (_jsInterop is not null) + { + await _jsInterop.AlignToItemAsync(localIndex, token); + } + } + + private int ClampToItemRange(int requested) + { + if (_itemCount <= 0) + { + return Math.Max(0, requested); + } + if (requested < 0) + { + return 0; + } + if (requested >= _itemCount) + { + return _itemCount - 1; + } + return requested; + } + /// protected override void OnParametersSet() { @@ -267,11 +435,27 @@ protected override void OnParametersSet() _itemTemplate = ItemContent ?? ChildContent; _placeholder = Placeholder ?? DefaultPlaceholder; _emptyContent = EmptyContent; + + // Pre-position the window at InitialIndex before the first render so the initial + // ItemsProvider fetch targets the right slice and avoids a flash of item 0. + if (!_initialScrollApplied && InitialIndex > 0) + { + MoveWindowToContain(InitialIndex); + } } /// protected override async Task OnAfterRenderAsync(bool firstRender) { + // Wait until the loaded slice matches the window; earlier renders measure stale DOM. + var pendingRenderTcs = _nextRenderTcs; + if (pendingRenderTcs is not null && _loadedItemsStartIndex == _itemsBefore && (_lastRenderedItemCount > 0 || _itemCount == 0)) + { + _nextRenderTcs = null; + _inFlightScrollHasRendered = true; + pendingRenderTcs.TrySetResult(); + } + if (firstRender) { _jsInterop = new VirtualizeJsInterop(this, JSRuntime); @@ -294,9 +478,10 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await _jsInterop.SetAnchorModeAsync((int)AnchorMode); } - // If a mutation captured an anchor snapshot before render, - // restore it now to keep the same row at the same viewport offset. - var shouldRestore = _pendingAnchorRestore && !_pendingScrollToBottom; + // If a mutation captured an anchor snapshot, restore it to keep the same row at the + // same viewport offset. Skip while a ScrollToIndexAsync is in flight — we are + // intentionally moving the viewport. + var shouldRestore = _pendingAnchorRestore && !_pendingScrollToBottom && _currentScrollCts is null; _pendingAnchorRestore = false; if (shouldRestore) @@ -306,6 +491,21 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await _jsInterop.RefreshObserversAsync(); } + + // Apply InitialIndex once: drive the first fetch via ScrollToIndexAsync rather than + // letting the spacer-IO callback fire at scrollTop=0 and reset the window to index 0. + if (!_initialScrollApplied && _jsInterop is not null) + { + if (InitialIndex > 0) + { + _initialScrollApplied = true; + await ScrollToIndexAsync(InitialIndex); + } + else if (_itemCount > 0) + { + _initialScrollApplied = true; + } + } } /// @@ -431,9 +631,35 @@ private bool ProcessMeasurements(float spacerSeparation) return false; } + private bool ShouldSuppressSpacerCallback() + { + // Before the initial ScrollToIndexAsync runs, ignore IO callbacks: at scrollTop=0 + // they would compute itemsBefore=0 and overwrite the pre-positioned window. + if (!_initialScrollApplied && InitialIndex > 0) + { + return true; + } + + if (_currentScrollCts is null) + { + return false; + } + if (_inFlightScrollHasRendered) + { + // After our render commits, IO callbacks reflect the alignToItem-driven scrollTop change — suppress them. + return true; + } + // Before our render commits, IO callbacks reflect a real user scroll: cancel the + // programmatic scroll and the in-flight provider call so the user's window wins. + _currentScrollCts.Cancel(); + _currentScrollCts = null; + _refreshCts?.Cancel(); + return false; + } + void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) { - if (_pendingAnchorRestore) + if (_pendingAnchorRestore || ShouldSuppressSpacerCallback()) { return; } @@ -453,7 +679,7 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) { - if (_pendingAnchorRestore) + if (_pendingAnchorRestore || ShouldSuppressSpacerCallback()) { return; } @@ -598,6 +824,17 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess) { var result = await _itemsProvider(request); + // InitialIndex out-of-range or TotalItemCount shrank between fetches: re-clamp + if (!cancellationToken.IsCancellationRequested + && result.TotalItemCount > 0 + && _itemsBefore >= result.TotalItemCount) + { + _itemCount = result.TotalItemCount; + MoveWindowToContain(_itemsBefore); + request = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken); + result = await _itemsProvider(request); + } + // Only apply result if the task was not canceled. if (!cancellationToken.IsCancellationRequested) { @@ -682,6 +919,9 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess) // Cache this exception so the renderer can throw it. _refreshException = e; + // Surface the exception to any waiting ScrollToIndexAsync caller. + _nextRenderTcs?.TrySetException(e); + // Re-render the component to throw the exception. StateHasChanged(); } @@ -730,6 +970,9 @@ public async ValueTask DisposeAsync() { _refreshCts?.Cancel(); + _currentScrollCts?.Cancel(); + _nextRenderTcs?.TrySetCanceled(CancellationToken.None); + if (_jsInterop != null) { await _jsInterop.DisposeAsync(); diff --git a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs index 4503d31434f1..39f85846074a 100644 --- a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs +++ b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs @@ -62,6 +62,16 @@ public ValueTask RestoreAnchorAsync() return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.restoreAnchor", _selfReference); } + public ValueTask AlignToItemAsync(int localIndex, CancellationToken cancellationToken = default) + { + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.alignToItem", cancellationToken, _selfReference, localIndex); + } + + public ValueTask BeginProgrammaticScrollAsync() + { + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.beginProgrammaticScroll", _selfReference); + } + public async ValueTask DisposeAsync() { if (_selfReference != null) diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 17d0b176378d..11db48e76e58 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -727,7 +727,8 @@ await testRenderer.Dispatcher.InvokeAsync(() => private async Task<(Virtualize virtualize, TestRenderer renderer)> CreateRenderedVirtualize( float itemSize, int totalItems, - ItemsProviderDelegate customProvider = null) + ItemsProviderDelegate customProvider = null, + RenderFragment childContent = null) { Virtualize renderedVirtualize = null; @@ -738,7 +739,7 @@ await testRenderer.Dispatcher.InvokeAsync(() => var rootComponent = new VirtualizeTestHostcomponent { - InnerContent = BuildVirtualize(itemSize, provider, null, virtualize => renderedVirtualize = virtualize) + InnerContent = BuildVirtualize(itemSize, provider, null, virtualize => renderedVirtualize = virtualize, childContent) }; var serviceProvider = new ServiceCollection() @@ -764,7 +765,8 @@ private RenderFragment BuildVirtualize( float itemSize, ItemsProviderDelegate itemsProvider, ICollection items, - Action> captureRenderedVirtualize = null) + Action> captureRenderedVirtualize = null, + RenderFragment childContent = null) => builder => { builder.OpenComponent>(0); @@ -772,6 +774,11 @@ private RenderFragment BuildVirtualize( builder.AddComponentParameter(2, "ItemsProvider", itemsProvider); builder.AddComponentParameter(3, "Items", items); + if (childContent != null) + { + builder.AddComponentParameter(5, "ChildContent", childContent); + } + if (captureRenderedVirtualize != null) { builder.AddComponentReferenceCapture(4, component => captureRenderedVirtualize(component as Virtualize)); @@ -963,4 +970,249 @@ await testRenderer.Dispatcher.InvokeAsync(() => $"In-memory append on value-type TItem must not trigger prepend detection (shift by countDelta). " + $"Before: {itemsBeforeAfterInit}, After: {renderedVirtualize._itemsBefore}, Shift: {shift}"); } + + [Fact] + public void ScrollToIndexAsync_ThrowsBeforeJsInteropInitialized() + { + var virtualize = new Virtualize(); + + var ex = Assert.Throws( + (Action)(() => { _ = virtualize.ScrollToIndexAsync(0); })); + Assert.Contains(nameof(Virtualize.ScrollToIndexAsync), ex.Message); + Assert.Contains(nameof(Virtualize.InitialIndex), ex.Message); + } + + private static readonly RenderFragment SimpleItemTemplate = item => b => b.AddContent(0, item); + + [Fact] + public async Task ScrollToIndexAsync_NegativeIndexDoesNotThrow() + { + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, childContent: SimpleItemTemplate); + + var callbacks = (IVirtualizeJsCallbacks)virtualize; + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); + + Task task = null; + await renderer.Dispatcher.InvokeAsync(() => { task = virtualize.ScrollToIndexAsync(-5); }); + await task; + Assert.True(task.IsCompletedSuccessfully); + // Clamp post-condition: negative index lands the window at the start. + Assert.Equal(0, virtualize._itemsBefore); + } + + [Fact] + public async Task ScrollToIndexAsync_IndexBeyondCountDoesNotThrow() + { + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, childContent: SimpleItemTemplate); + + var callbacks = (IVirtualizeJsCallbacks)virtualize; + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); + + Task task = null; + await renderer.Dispatcher.InvokeAsync(() => { task = virtualize.ScrollToIndexAsync(99_999); }); + + await task; + Assert.True(task.IsCompletedSuccessfully); + // Clamp post-condition: out-of-range index lands the window at the end. After + // OnAfterSpacerVisible with container=500/itemSize=50, capacity = ceil(500/50) + 2*15 = 40, + // so the last full window starts at max(0, 100 - 40) = 60. + Assert.Equal(60, virtualize._itemsBefore); + } + + [Fact] + public async Task ScrollToIndexAsync_EmptyListCompletesAsNoOp() + { + // Regression: render-commit rendezvous used to wait for _lastRenderedItemCount > 0, which never becomes true for an empty list, so the Task hung forever. + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 0); + + Task task = null; + await renderer.Dispatcher.InvokeAsync(() => { task = virtualize.ScrollToIndexAsync(0); }); + + await task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(task.IsCompletedSuccessfully); + } + + [Fact] + public async Task ScrollToIndexAsync_AlreadyCancelledTokenProducesCancelledTask() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Task task = null; + await renderer.Dispatcher.InvokeAsync(() => { task = virtualize.ScrollToIndexAsync(10, cts.Token); }); + + await Assert.ThrowsAnyAsync(() => task); + } + + [Fact] + public async Task ScrollToIndexAsync_SecondCallDoesNotFaultFirstTask() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 1000); + + var callbacks = (IVirtualizeJsCallbacks)virtualize; + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f)); + + var (firstTask, secondTask) = await renderer.Dispatcher.InvokeAsync(() => + { + var first = virtualize.ScrollToIndexAsync(500); + var second = virtualize.ScrollToIndexAsync(750); + return (first, second); + }); + + await firstTask; + Assert.True(firstTask.IsCompletedSuccessfully); + + if (secondTask.IsCompleted) + { + Assert.True(secondTask.IsCompletedSuccessfully, secondTask.Exception?.ToString()); + } + } + + [Fact] + public async Task ScrollToIndexAsync_ProviderThrows_FaultsTaskInsteadOfHanging() + { + var sentinel = new InvalidOperationException("provider boom"); + ItemsProviderDelegate throwingProvider = _ => throw sentinel; + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, customProvider: throwingProvider); + + Task task = null; + await renderer.Dispatcher.InvokeAsync(() => { task = virtualize.ScrollToIndexAsync(50); }); + + var ex = await Assert.ThrowsAnyAsync(async () => await task.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Same(sentinel, ex); + } + + [Fact] + public async Task InitialIndex_ParameterRoundTrip() + { + Virtualize renderedVirtualize = null; + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "ItemSize", 50f); + builder.AddComponentParameter(2, "Items", (ICollection)Enumerable.Range(1, 100).ToList()); + builder.AddComponentParameter(3, "InitialIndex", 42); + builder.AddComponentParameter(4, "ChildContent", (RenderFragment)(item => b => + { + b.OpenElement(0, "span"); + b.AddContent(1, item); + b.CloseElement(); + })); + builder.AddComponentReferenceCapture(5, c => renderedVirtualize = (Virtualize)c); + builder.CloseComponent(); + } + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + Assert.NotNull(renderedVirtualize); + Assert.Equal(42, renderedVirtualize.InitialIndex); + } + + [Fact] + public async Task InitialIndex_DefaultZeroMeansNoInitialScroll() + { + Virtualize renderedVirtualize = null; + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), + v => renderedVirtualize = v) + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + Assert.NotNull(renderedVirtualize); + Assert.Equal(0, renderedVirtualize.InitialIndex); + // No initial-scroll request was issued; component opens at item 0. + Assert.Equal(0, renderedVirtualize._itemsBefore); + } + + [Fact] + public async Task InitialIndex_NegativeClampsToZero() + { + Virtualize renderedVirtualize = null; + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "ItemSize", 50f); + builder.AddComponentParameter(2, "Items", Enumerable.Range(1, 100).ToList() as ICollection); + builder.AddComponentParameter(3, "InitialIndex", -5); + builder.AddComponentParameter(4, "ChildContent", (RenderFragment)(item => b => b.AddContent(0, item.ToString(System.Globalization.CultureInfo.InvariantCulture)))); + builder.AddComponentReferenceCapture(5, c => renderedVirtualize = c as Virtualize); + builder.CloseComponent(); + } + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + Assert.NotNull(renderedVirtualize); + Assert.Equal(-5, renderedVirtualize.InitialIndex); + // Negative InitialIndex must clamp to 0 (no out-of-range seeding of _itemsBefore). + Assert.Equal(0, renderedVirtualize._itemsBefore); + } + + [Fact] + public async Task InitialIndex_BeyondCountClampsToEnd() + { + Virtualize renderedVirtualize = null; + var items = Enumerable.Range(1, 100).ToList(); + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "ItemSize", 50f); + builder.AddComponentParameter(2, "Items", items as ICollection); + builder.AddComponentParameter(3, "InitialIndex", 99999); + builder.AddComponentParameter(4, "ChildContent", (RenderFragment)(item => b => b.AddContent(0, item.ToString(System.Globalization.CultureInfo.InvariantCulture)))); + builder.AddComponentReferenceCapture(5, c => renderedVirtualize = c as Virtualize); + builder.CloseComponent(); + } + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => Mock.Of()) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + Assert.NotNull(renderedVirtualize); + Assert.Equal(99999, renderedVirtualize.InitialIndex); + // For a fixed Items collection, the seed clamps using the now-known count: max(0, Count - capacity). + // capacity = OverscanCount*2 + 1 = 31 with default OverscanCount=15, so _itemsBefore = max(0, 100 - 31) = 69. + Assert.Equal(69, renderedVirtualize._itemsBefore); + } } diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 4639b009939b..0370730e5ce5 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Globalization; +using System.Linq; using BasicTestApp; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; @@ -2585,6 +2587,38 @@ public void AnchorMode_None_AsyncProvider_ScrollDoesNotFlash() var items = container.querySelectorAll('.item[data-index]'); var containerRect = container.getBoundingClientRect(); let topIndex = -1; + let topIsPlaceholder = false; + + // Detect whether the topmost-visible row is a placeholder (no data-index) + var topCandidates = container.querySelectorAll('.item[data-index], .loading-placeholder'); + for (var k = 0; k < topCandidates.length; k++) { + var rk = topCandidates[k].getBoundingClientRect(); + if (rk.bottom > containerRect.top + 2 && rk.top < containerRect.bottom - 2) { + topIsPlaceholder = !topCandidates[k].hasAttribute('data-index'); + break; + } + } + + // If the viewport is all placeholders, the async provider's in-flight fetch + // is being repeatedly cancelled by our fast scroll loop. Pause briefly so + // it can complete — this preserves the flash assertion (prevTopIndex carries + // across the gap) and keeps forward-progress on slow CI agents. + let waited = 0; + while ((items.length === 0 || topIsPlaceholder) && waited < 1500) { + await new Promise(r => setTimeout(r, 100)); + waited += 100; + items = container.querySelectorAll('.item[data-index]'); + containerRect = container.getBoundingClientRect(); + topIsPlaceholder = false; + topCandidates = container.querySelectorAll('.item[data-index], .loading-placeholder'); + for (var k = 0; k < topCandidates.length; k++) { + var rk = topCandidates[k].getBoundingClientRect(); + if (rk.bottom > containerRect.top + 2 && rk.top < containerRect.bottom - 2) { + topIsPlaceholder = !topCandidates[k].hasAttribute('data-index'); + break; + } + } + } for (var j = 0; j < items.length; j++) { var rect = items[j].getBoundingClientRect(); @@ -2606,18 +2640,22 @@ public void AnchorMode_None_AsyncProvider_ScrollDoesNotFlash() if (container.scrollHeight - container.scrollTop - container.clientHeight < 2) break; } - done({ flashCount: flashCount, maxIndexSeen: maxIndexSeen }); + done({ + flashCount: flashCount, + maxIndexSeen: maxIndexSeen + }); })(); ", container) as Dictionary; - var flashCount = Convert.ToInt32(result["flashCount"], CultureInfo.InvariantCulture); - var maxIndexSeen = Convert.ToInt32(result["maxIndexSeen"], CultureInfo.InvariantCulture); + int Int(string k) => Convert.ToInt32(result[k], CultureInfo.InvariantCulture); + var flashCount = Int("flashCount"); + var maxIndexSeen = Int("maxIndexSeen"); Assert.True(flashCount == 0, $"Async provider: scrolling should not flash/jump backward. " + - $"Detected {flashCount} backward jumps, max index seen: {maxIndexSeen}"); + $"Detected {flashCount} backward jumps, max index seen: {maxIndexSeen}."); Assert.True(maxIndexSeen >= 50, - $"Should have scrolled through some items but only reached index {maxIndexSeen}"); + $"Should have scrolled through some items but only reached index {maxIndexSeen}."); } [Theory] @@ -3943,4 +3981,468 @@ private Dictionary ExecuteViewportScrollJumpDetectionScript( return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); } + + private void MountAnchorModeForScrollToItem(bool variableHeight = false, bool delay = false) + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("scroll-container")); + Browser.True(() => GetElementCount(container, ".item") > 0); + + // All ScrollToItem tests use ItemsProvider per design. + Browser.Exists(By.Id("toggle-provider")).Click(); + Browser.True(() => GetElementCount(container, ".item") > 0); + + if (variableHeight) + { + Browser.Exists(By.Id("toggle-height")).Click(); + Browser.True(() => GetElementCount(container, ".item") > 0); + } + + if (delay) + { + Browser.Exists(By.Id("toggle-delay")).Click(); + } + } + + private void SetScrollTargetIndex(int index) => SetNumberInputAndWaitForBind("scroll-target-index", index); + + private void SetManualInitialIndex(int index) => SetNumberInputAndWaitForBind("manual-initial-index", index); + + // Types into and polls the sibling {id}-bound span until the bound model commits (needed on Server where @bind round-trips over SignalR). + private void SetNumberInputAndWaitForBind(string elementId, int value) + { + var expected = value.ToString(CultureInfo.InvariantCulture); + var input = Browser.Exists(By.Id(elementId)); + // Clear() on is unreliable across drivers; Ctrl+A + Delete works. + input.SendKeys(Keys.Control + "a"); + input.SendKeys(Keys.Delete); + input.SendKeys(expected); + input.SendKeys(Keys.Tab); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => (string)js.ExecuteScript( + "return document.getElementById(arguments[0] + '-bound')?.getAttribute('data-value');", + elementId) == expected); + } + + private void WaitForScrollStatus(string expected, int timeoutSeconds = 30) + { + try + { + Browser.True(() => + Browser.Exists(By.Id("scroll-status")).Text.StartsWith(expected, StringComparison.Ordinal), + TimeSpan.FromSeconds(timeoutSeconds)); + } + catch (Exception ex) + { + string actual; + try { actual = Browser.Exists(By.Id("scroll-status")).Text; } + catch { actual = ""; } + string targetInputVal; + try + { + targetInputVal = (string)((IJavaScriptExecutor)Browser).ExecuteScript( + "return document.getElementById('scroll-target-index')?.value ?? '';"); + } + catch { targetInputVal = ""; } + throw new Exception( + $"WaitForScrollStatus timeout: expected status to start with '{expected}', " + + $"actual='{actual}', scroll-target-index value='{targetInputVal}'", ex); + } + } + + // Returns the data-index of the topmost rendered .item visible inside #scroll-container. + // If the topmost rendered child is a placeholder, returns -1. + private long GetTopRenderedIndex(IJavaScriptExecutor js) + { + return (long)js.ExecuteScript(@" + var container = document.getElementById('scroll-container'); + var rect = container.getBoundingClientRect(); + var items = container.querySelectorAll('.item, .loading-placeholder'); + var best = null; + var bestTop = Number.POSITIVE_INFINITY; + for (var i = 0; i < items.length; i++) { + var ir = items[i].getBoundingClientRect(); + // 1px tolerance: a sub-pixel sliver of the previous item just above the boundary + // (from variable-height browser rounding) shouldn't count as 'topmost visible'. + if (ir.bottom <= rect.top + 1) continue; // above viewport + if (ir.top < bestTop) { bestTop = ir.top; best = items[i]; } + } + if (!best) return -1; + if (best.classList.contains('loading-placeholder')) return -1; + var idx = best.getAttribute('data-index'); + return idx === null ? -1 : parseInt(idx, 10); + "); + } + + private long GetScrollTop(IJavaScriptExecutor js, IWebElement container) + => (long)js.ExecuteScript("return Math.round(arguments[0].scrollTop)", container); + + [Fact] + public void ScrollToItem_FixedHeight_LandsAtTop() + { + MountAnchorModeForScrollToItem(); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + SetScrollTargetIndex(200); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 200"); + + Browser.True(() => GetTopRenderedIndex(js) == 200, + $"Top rendered item should be 200 but was {GetTopRenderedIndex(js)}, scrollTop={GetScrollTop(js, container)}"); + } + + [Fact] + public void ScrollToItem_VariableHeight_LandsAtTop() + { + MountAnchorModeForScrollToItem(variableHeight: true); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + SetScrollTargetIndex(200); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 200"); + + Browser.True(() => GetTopRenderedIndex(js) == 200, + $"Variable-height: top rendered item should be 200 but was {GetTopRenderedIndex(js)}, scrollTop={GetScrollTop(js, container)}"); + } + + [Fact] + public void ScrollToItem_NegativeIndex_ScrollsToTop() + { + MountAnchorModeForScrollToItem(); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // First scroll forward so we have somewhere to come back from. + SetScrollTargetIndex(150); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 150"); + + SetScrollTargetIndex(-1); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: -1"); + + Browser.True(() => GetScrollTop(js, container) <= 1, + $"Negative index should clamp to top; scrollTop={GetScrollTop(js, container)}"); + Browser.True(() => GetTopRenderedIndex(js) == 0); + } + + [Fact] + public void ScrollToItem_MaxIntIndex_ScrollsToLast() + { + MountAnchorModeForScrollToItem(); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + SetScrollTargetIndex(int.MaxValue); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: " + int.MaxValue.ToString(CultureInfo.InvariantCulture)); + + // 1000 items total; last is index 999. After clamp it should be visible at the top of the viewport + // (or as close as possible — at the very end the page can't scroll further). + Browser.True(() => + { + var st = GetScrollTop(js, container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return st > 0 && st >= sh - ch - 2; + }, message: "Scroll should be at the bottom (last item region) after clamp to MaxValue."); + + // The last item must be present in the DOM and inside the viewport. + Browser.True(() => + (bool)js.ExecuteScript(@" + var el = document.querySelector('#scroll-container .item[data-index=""999""]'); + if (!el) return false; + var cr = document.getElementById('scroll-container').getBoundingClientRect(); + var er = el.getBoundingClientRect(); + return er.top < cr.bottom && er.bottom > cr.top; + ")); + } + + [Fact] + public void ScrollToItem_ForwardThenBackward() + { + MountAnchorModeForScrollToItem(); + var js = (IJavaScriptExecutor)Browser; + + SetScrollTargetIndex(300); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 300"); + + SetScrollTargetIndex(50); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 50"); + + Browser.True(() => GetTopRenderedIndex(js) == 50, + $"Top rendered item should be 50 after backward scroll but was {GetTopRenderedIndex(js)}"); + } + + [Fact] + public void ScrollToItem_WithProviderDelay_NoPlaceholderAtTarget() + { + // When ScrollToIndexAsync completes, the target row must show content, not placeholder. + MountAnchorModeForScrollToItem(delay: true); + var js = (IJavaScriptExecutor)Browser; + + SetScrollTargetIndex(300); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 300", timeoutSeconds: 30); + + Browser.True(() => GetTopRenderedIndex(js) == 300, + $"Top rendered should be real item 300 (not placeholder); got {GetTopRenderedIndex(js)}"); + + // Sanity: the .loading-placeholder template must be wired. + var topIsPlaceholder = (bool)js.ExecuteScript(@" + var container = document.getElementById('scroll-container'); + var rect = container.getBoundingClientRect(); + var els = container.querySelectorAll('.item, .loading-placeholder'); + for (var i = 0; i < els.length; i++) { + var er = els[i].getBoundingClientRect(); + if (er.bottom > rect.top && er.top < rect.top + 5) { + return els[i].classList.contains('loading-placeholder'); + } + } + return false; + "); + Assert.False(topIsPlaceholder, "Topmost element at viewport top must not be a loading placeholder."); + } + + [Theory] + [InlineData(1, false, false)] + [InlineData(5, false, false)] + [InlineData(500, false, false)] + [InlineData(1, false, true)] + [InlineData(500, false, true)] + [InlineData(1, true, false)] + [InlineData(500, true, false)] + [InlineData(1, true, true)] + [InlineData(500, true, true)] + public void InitialIndex_OpensAtTargetWithRealContent(int initialIndex, bool variableHeight, bool delay) + { + MountAnchorModeForScrollToItem(variableHeight: variableHeight, delay: delay); + var js = (IJavaScriptExecutor)Browser; + + Browser.Exists(By.Id("unload-list")).Click(); + Browser.Exists(By.Id("list-not-loaded")); + js.ExecuteScript("document.getElementById('scroll-container').scrollTop = 0;"); + SetManualInitialIndex(initialIndex); + Browser.Exists(By.Id("reload-with-initial-index")).Click(); + + // The target item must sit at the top of the viewport even for small indices and with a delayed provider. + Browser.True(() => GetTopRenderedIndex(js) == initialIndex); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InitialIndex_BeyondCount_ClampsToEnd(bool delay) + { + MountAnchorModeForScrollToItem(delay: delay); + var js = (IJavaScriptExecutor)Browser; + + Browser.Exists(By.Id("unload-list")).Click(); + Browser.Exists(By.Id("list-not-loaded")); + js.ExecuteScript("document.getElementById('scroll-container').scrollTop = 0;"); + SetManualInitialIndex(100000); + Browser.Exists(By.Id("reload-with-initial-index")).Click(); + + // After clamping, the last item (999) must be rendered and the scroller pinned to the end + // (the browser clamps scrollTop to its maximum since 999 cannot be aligned to the top of the viewport). + Browser.True(() => (bool)js.ExecuteScript(@" + var c = document.getElementById('scroll-container'); + var last = c.querySelector('.item[data-index=""999""]'); + if (!last) return false; + // Scroller must be pinned at max (within 2px) — proves the clamp targeted the end. + return Math.abs((c.scrollTop + c.clientHeight) - c.scrollHeight) <= 2; + "), "Expected last item (999) rendered and scroller pinned at end after clamping InitialIndex=100000."); + } + + [Theory] + [InlineData(120)] + [InlineData(300)] + public void ScrollToItem_AsyncProvider_VariableHeight_WithDelay_ReachesTarget(int target) + { + // Combines the two hardest dimensions: variable-height measurement and a delayed ItemsProvider. + MountAnchorModeForScrollToItem(variableHeight: true, delay: true); + var js = (IJavaScriptExecutor)Browser; + + SetScrollTargetIndex(target); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus($"Completed: {target}", timeoutSeconds: 30); + + Browser.True(() => GetTopRenderedIndex(js) == target, + $"Top rendered should be real item {target}; got {GetTopRenderedIndex(js)}"); + } + + [Fact] + public void ScrollToItem_RapidCalls_OnlyLastTargetReached() + { + // Q4: cancel-and-switch — even with 5 calls back-to-back, the final target wins. + MountAnchorModeForScrollToItem(delay: true); + var js = (IJavaScriptExecutor)Browser; + + Browser.Exists(By.Id("scroll-to-item-rapid")).Click(); + // 5 calls fired back-to-back: supersession completes normally, so all 5 tasks finish. + WaitForScrollStatus("Completed: 250 (canceled=0, completed=5, faulted=0)", timeoutSeconds: 30); + + Browser.True(() => GetTopRenderedIndex(js) == 250, + $"After rapid-fire scroll, top should be 250 but was {GetTopRenderedIndex(js)}"); + } + + [Fact] + public void ScrollToItem_ExternalCancellation_TaskCancels() + { + MountAnchorModeForScrollToItem(delay: true); + var js = (IJavaScriptExecutor)Browser; + + SetScrollTargetIndex(450); + Browser.Exists(By.Id("scroll-to-item-cancellable")).Click(); + // Cancel before the 500ms provider delay completes. + Browser.Exists(By.Id("cancel-scroll")).Click(); + WaitForScrollStatus("Canceled"); + + // Component should still respond to a fresh scroll. + SetScrollTargetIndex(120); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 120", timeoutSeconds: 30); + Browser.True(() => GetTopRenderedIndex(js) == 120); + } + + [Fact] + public void ScrollToItem_UserScrollDuringProviderFetch_UserScrollWins() + { + // While the provider is fetching for ScrollToIndexAsync, a real user scroll must win. + MountAnchorModeForScrollToItem(); + var js = (IJavaScriptExecutor)Browser; + var container = Browser.Exists(By.Id("scroll-container")); + + // Arm the gate. The initial load already finished above, so the *next* provider + // call will be the first gated one (counter starts at 0 -> becomes 1 on entry). + Browser.Exists(By.Id("toggle-provider-gate")).Click(); + + // Trigger a scroll to row 800. This fires call #1 through the gate. + SetScrollTargetIndex(800); + Browser.Exists(By.Id("scroll-to-item")).Click(); + Browser.True(() => GetProviderCallIndex(js) >= 1); + Browser.True(() => GetProviderEvents(js).Contains("p1-enter")); + Browser.True(() => GetProviderEvents(js).Contains("scroll-start")); + + // While call #1 is still blocked, simulate a real user scroll far from row 800. + // The scroll event triggers spacer IO -> the fix cancels _currentScrollCts -> + // call #1's WaitAsync(ct) throws OCE -> RefreshDataCoreAsync starts call #2 for + // the user's window. The caller observes OperationCanceledException. + js.ExecuteScript("arguments[0].scrollTop = arguments[1];", container, 5000); + + Browser.True(() => GetProviderEvents(js).Contains("p1-cancel")); + Browser.True(() => GetProviderCallIndex(js) >= 2); + Browser.True(() => GetProviderEvents(js).Contains("p2-enter")); + // Caller's Task completes normally per existing contract (supersession/user-scroll don't fault). + WaitForScrollStatus("Completed: 800"); + + // Release the gate. A user scroll typically triggers multiple RefreshDataCoreAsync + // calls (one per spacer-IO callback), each superseding the previous _refreshCts. + // Whichever provider call is waiting on the current gate TCS at release time wakes up. + // We just need *some* later call to return so the final user-window items render. + Browser.Exists(By.Id("release-provider-gate")).Click(); + Browser.True(() => + { + var parts = GetProviderEvents(js).Split('|'); + var cancelIdx = Array.IndexOf(parts, "p1-cancel"); + if (cancelIdx < 0) + { + return false; + } + return parts.Skip(cancelIdx + 1) + .Any(e => e.StartsWith("p", StringComparison.Ordinal) && e.EndsWith("-return", StringComparison.Ordinal)); + }, $"Expected some pN-return after p1-cancel. Events: {GetProviderEvents(js)}"); + + // Causal-order assertions. The scroll-target fetch (call #1) must never have + // returned successfully -- that's the real proof the user scroll won. + var log = GetProviderEvents(js); + Assert.DoesNotContain("p1-return", log); + Assert.True(IndexOf(log, "scroll-start") < IndexOf(log, "p1-enter"), + $"scroll-start should precede p1-enter; events={log}"); + Assert.True(IndexOf(log, "p1-enter") < IndexOf(log, "p1-cancel"), + $"p1-enter should precede p1-cancel; events={log}"); + + // Final viewport: the user scrolled near the top (~row 100), NOT row 800. + Browser.True(() => + { + var top = GetTopRenderedIndex(js); + return top >= 0 && top < 250; + }, $"Top rendered should reflect the user scroll (< 250), but was {GetTopRenderedIndex(js)}"); + } + + private static long GetProviderCallIndex(IJavaScriptExecutor js) + { + var raw = (string)js.ExecuteScript( + "return document.getElementById('provider-call-index')?.getAttribute('data-value');"); + return long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0; + } + + private static string GetProviderEvents(IJavaScriptExecutor js) + { + return (string)js.ExecuteScript( + "return document.getElementById('provider-events')?.getAttribute('data-value') ?? '';"); + } + + private static int IndexOf(string log, string token) + { + var i = log.IndexOf(token, StringComparison.Ordinal); + return i < 0 ? int.MaxValue : i; + } + + [Fact] + public void ScrollToItem_AnchorBeginning_AtTop_LandsAtTarget() + { + // Anchor restore must NOT fight an active scroll. + MountAnchorModeForScrollToItem(); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // AnchorMode is Beginning by default; verify we're at the top. + Browser.True(() => GetScrollTop(js, container) == 0); + + SetScrollTargetIndex(2); + Browser.Exists(By.Id("scroll-to-item")).Click(); + WaitForScrollStatus("Completed: 2"); + + Browser.True(() => GetTopRenderedIndex(js) == 2, + $"With AnchorMode=Beginning, top should be 2 but was {GetTopRenderedIndex(js)}"); + } + + [Fact] + public void WindowScroll_ScrollToItem_LandsAtTarget() + { + Browser.MountTestComponent(); + var root = Browser.Exists(By.Id("virtualize-root")); + Browser.True(() => GetElementCount(root, ".item") > 0); + + Browser.Exists(By.Id("toggle-provider")).Click(); + Browser.True(() => GetElementCount(root, ".item") > 0); + + var input = Browser.Exists(By.Id("scroll-target-index")); + input.SendKeys(Keys.Control + "a"); + input.SendKeys(Keys.Delete); + input.SendKeys("100"); + input.SendKeys(Keys.Tab); + Browser.Exists(By.Id("scroll-to-item")).Click(); + Browser.True(() => + Browser.Exists(By.Id("scroll-status")).Text.StartsWith("Completed: 100", StringComparison.Ordinal), + TimeSpan.FromSeconds(15)); + + var js = (IJavaScriptExecutor)Browser; + var scrollY = (long)js.ExecuteScript("return Math.round(window.scrollY)"); + Assert.True(scrollY > 0, $"Window scroll should have moved; scrollY={scrollY}"); + + // Item 100 must be visible in the viewport. + Browser.True(() => + (bool)js.ExecuteScript(@" + var el = document.querySelector('.item[data-index=""100""]'); + if (!el) return false; + var er = el.getBoundingClientRect(); + return er.top < window.innerHeight && er.bottom > 0; + "), "Item 100 must be visible in window viewport."); + } } diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor index e46da1494f3e..8c0110bad56e 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -1,4 +1,6 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization +@using System.Linq +@using System.Threading

Virtualization Anchor Mode

@@ -13,30 +15,65 @@
- @if (useItemsProvider) + @if (listLoaded) { - -
-
Item @item.Index
-
-
+ @if (useItemsProvider) + { + + +
+
Item @item.Index
+
+
+ +
+ Loading... +
+
+
+ } + else + { + +
+
Item @item.Index
+
+
+ } } else { - -
-
Item @item.Index
-
-
+

List not loaded. Set an Initial Index below and click "Load list".

}
+
+ + + + bound:@manualInitialIndex +
+ +
+ + bound:@scrollTargetIndex + + + + +
+ +

@scrollStatus

+
@@ -56,10 +93,16 @@ + +

@statusMessage

@((int)anchorMode)

+

@providerCallIndex

+

@string.Join("|", providerEvents)

@code { private List items = new(); @@ -68,7 +111,17 @@ private bool useVariableHeight = false; private bool useItemsProvider = false; private bool useProviderDelay = false; + private bool useProviderGate = false; + private TaskCompletionSource providerGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int providerCallIndex; + private List providerEvents = new(); private Virtualize virtualizeRef; + private int initialIndex; + private int manualInitialIndex; + private bool listLoaded = true; + private int scrollTargetIndex = 200; + private string scrollStatus = "Idle"; + private CancellationTokenSource _userScrollCts; private static readonly IEqualityComparer _itemComparer = EqualityComparer.Create((a, b) => a.Index == b.Index, item => item.Index); @@ -80,7 +133,7 @@ protected override void OnInitialized() { - items = Enumerable.Range(0, 500) + items = Enumerable.Range(0, 1000) .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) .ToList(); } @@ -110,7 +163,7 @@ } private int nextPrependIndex = -1; - private int nextAppendIndex = 500; + private int nextAppendIndex = 1000; private async Task PrependItems() { @@ -274,6 +327,25 @@ { await Task.Delay(500); } + if (useProviderGate) + { + var n = Interlocked.Increment(ref providerCallIndex); + var gate = providerGate; + lock (providerEvents) providerEvents.Add($"p{n}-enter"); + await InvokeAsync(StateHasChanged); + try + { + await gate.Task.WaitAsync(request.CancellationToken); + lock (providerEvents) providerEvents.Add($"p{n}-return"); + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) + { + lock (providerEvents) providerEvents.Add($"p{n}-cancel"); + await InvokeAsync(StateHasChanged); + throw; + } + } // Apply pending mutations (simulates fetching updated data from a DB). if (_pendingPrepend != null) @@ -310,6 +382,107 @@ statusMessage = useProviderDelay ? "Provider delay: 500ms" : "Provider delay: None"; } + private void ToggleProviderGate() + { + useProviderGate = !useProviderGate; + statusMessage = useProviderGate ? "Provider gate: On" : "Provider gate: Off"; + } + + private void ReleaseProviderGate() + { + var old = providerGate; + providerGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + old.TrySetResult(); + } + + private async Task ReloadWithInitialIndex() + { + listLoaded = false; + StateHasChanged(); + await Task.Yield(); + initialIndex = manualInitialIndex; + listLoaded = true; + } + + private void UnloadList() + { + listLoaded = false; + } + + private async Task ScrollToTarget() + { + if (virtualizeRef == null) { scrollStatus = "Faulted: no virtualizeRef"; return; } + lock (providerEvents) providerEvents.Add("scroll-start"); + scrollStatus = $"Scrolling to {scrollTargetIndex}..."; + try + { + await virtualizeRef.ScrollToIndexAsync(scrollTargetIndex); + scrollStatus = $"Completed: {scrollTargetIndex}"; + lock (providerEvents) providerEvents.Add("scroll-complete"); + } + catch (OperationCanceledException) + { + scrollStatus = $"Canceled: {scrollTargetIndex}"; + lock (providerEvents) providerEvents.Add("scroll-cancel"); + } + catch (Exception ex) + { + scrollStatus = $"Faulted: {ex.Message}"; + } + } + + private async Task ScrollRapid() + { + if (virtualizeRef == null) { scrollStatus = "Faulted: no virtualizeRef"; return; } + scrollStatus = "Rapid scrolling..."; + // Fire 5 calls back-to-back; each subsequent call supersedes the previous. + // Superseded tasks complete normally — only the final target lands in the viewport. + var tasks = new[] + { + virtualizeRef.ScrollToIndexAsync(50), + virtualizeRef.ScrollToIndexAsync(400), + virtualizeRef.ScrollToIndexAsync(100), + virtualizeRef.ScrollToIndexAsync(350), + virtualizeRef.ScrollToIndexAsync(250), + }; + foreach (var t in tasks) { await SafeAwait(t); } + var canceled = tasks.Count(t => t.IsCanceled); + var completed = tasks.Count(t => t.Status == TaskStatus.RanToCompletion); + var faulted = tasks.Count(t => t.IsFaulted); + scrollStatus = $"Completed: 250 (canceled={canceled}, completed={completed}, faulted={faulted})"; + } + + private static async Task SafeAwait(Task t) + { + try { await t; } catch (OperationCanceledException) { } + } + + private async Task ScrollCancellable() + { + if (virtualizeRef == null) { scrollStatus = "Faulted: no virtualizeRef"; return; } + _userScrollCts?.Dispose(); + _userScrollCts = new CancellationTokenSource(); + scrollStatus = $"Scrolling to {scrollTargetIndex} (cancellable)..."; + try + { + await virtualizeRef.ScrollToIndexAsync(scrollTargetIndex, _userScrollCts.Token); + scrollStatus = $"Completed: {scrollTargetIndex}"; + } + catch (OperationCanceledException) + { + scrollStatus = "Canceled"; + } + catch (Exception ex) + { + scrollStatus = $"Faulted: {ex.Message}"; + } + } + + private void CancelScroll() + { + _userScrollCts?.Cancel(); + } + private class DynamicItem { public int Index { get; set; } diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor index 1e0f9e6893f0..1134f190ae49 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor @@ -27,9 +27,12 @@ + +

@statusMessage

@((int)anchorMode)

+

@scrollStatus

@@ -65,6 +68,8 @@ private bool useVariableHeight = false; private bool useItemsProvider = false; private Virtualize virtualizeRef; + private int scrollTargetIndex = 100; + private string scrollStatus = "Idle"; private static readonly IEqualityComparer _itemComparer = EqualityComparer.Create((a, b) => a.Index == b.Index, item => item.Index); @@ -231,6 +236,21 @@ return new ItemsProviderResult(result, items.Count); } + private async Task ScrollToTarget() + { + if (virtualizeRef == null) { scrollStatus = "Faulted: no virtualizeRef"; return; } + scrollStatus = $"Scrolling to {scrollTargetIndex}..."; + try + { + await virtualizeRef.ScrollToIndexAsync(scrollTargetIndex); + scrollStatus = $"Completed: {scrollTargetIndex}"; + } + catch (Exception ex) + { + scrollStatus = $"Faulted: {ex.Message}"; + } + } + private class DynamicItem { public int Index { get; set; }