diff --git a/src/Components/Analyzers/src/DiagnosticDescriptors.cs b/src/Components/Analyzers/src/DiagnosticDescriptors.cs index a6ab31103ce5..a582b4b4684a 100644 --- a/src/Components/Analyzers/src/DiagnosticDescriptors.cs +++ b/src/Components/Analyzers/src/DiagnosticDescriptors.cs @@ -102,4 +102,13 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Warning, isEnabledByDefault: true, description: CreateLocalizableResourceString(nameof(Resources.UseInvokeVoidAsyncForObjectReturn_Description))); + + public static readonly DiagnosticDescriptor VirtualizeItemsProviderRequiresItemComparer = new( + "BL0011", + CreateLocalizableResourceString(nameof(Resources.VirtualizeItemsProviderRequiresItemComparer_Title)), + CreateLocalizableResourceString(nameof(Resources.VirtualizeItemsProviderRequiresItemComparer_Format)), + Usage, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: CreateLocalizableResourceString(nameof(Resources.VirtualizeItemsProviderRequiresItemComparer_Description))); } diff --git a/src/Components/Analyzers/src/Resources.resx b/src/Components/Analyzers/src/Resources.resx index 7720ffa2d3c7..e6b49636b3b5 100644 --- a/src/Components/Analyzers/src/Resources.resx +++ b/src/Components/Analyzers/src/Resources.resx @@ -210,4 +210,13 @@ Use InvokeVoidAsync instead of InvokeAsync<object> + + Without ItemComparer, the component cannot detect whether items were prepended or appended, causing the viewport to jump when items change dynamically. + + + Virtualize uses 'ItemsProvider' without 'ItemComparer'. Set ItemComparer to an IEqualityComparer that identifies items by a unique key. + + + Virtualize with ItemsProvider requires ItemComparer + \ No newline at end of file diff --git a/src/Components/Analyzers/src/VirtualizeItemComparerAnalyzer.cs b/src/Components/Analyzers/src/VirtualizeItemComparerAnalyzer.cs new file mode 100644 index 000000000000..7482ea49e64b --- /dev/null +++ b/src/Components/Analyzers/src/VirtualizeItemComparerAnalyzer.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +#nullable enable + +namespace Microsoft.AspNetCore.Components.Analyzers; + +/// +/// Analyzer that detects usage of Virtualize with ItemsProvider but without ItemComparer. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class VirtualizeItemComparerAnalyzer : DiagnosticAnalyzer +{ + private const string VirtualizeTypeName = "Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize`1"; + private const string RenderTreeBuilderTypeName = "Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder"; + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.VirtualizeItemsProviderRequiresItemComparer); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + + context.RegisterCompilationStartAction(compilationContext => + { + var virtualizeType = compilationContext.Compilation.GetTypeByMetadataName(VirtualizeTypeName); + var renderTreeBuilderType = compilationContext.Compilation.GetTypeByMetadataName(RenderTreeBuilderTypeName); + + if (virtualizeType is null || renderTreeBuilderType is null) + { + return; + } + + compilationContext.RegisterOperationBlockStartAction(blockContext => + { + var componentStack = new Stack(); + var completedVirtualizeComponents = new List(); + + blockContext.RegisterOperationAction(operationContext => + { + var invocation = (IInvocationOperation)operationContext.Operation; + var targetMethod = invocation.TargetMethod; + + if (!SymbolEqualityComparer.Default.Equals(targetMethod.ContainingType, renderTreeBuilderType)) + { + return; + } + + switch (targetMethod.Name) + { + case "OpenComponent": + if (targetMethod.IsGenericMethod && targetMethod.TypeArguments.Length == 1) + { + var typeArg = targetMethod.TypeArguments[0]; + var originalDef = typeArg is INamedTypeSymbol namedType && namedType.IsGenericType + ? namedType.OriginalDefinition + : typeArg; + + if (SymbolEqualityComparer.Default.Equals(originalDef, virtualizeType)) + { + componentStack.Push(new ComponentState { IsVirtualize = true }); + } + else + { + componentStack.Push(new ComponentState { IsVirtualize = false }); + } + } + else + { + componentStack.Push(new ComponentState { IsVirtualize = false }); + } + break; + + case "AddComponentParameter": + if (componentStack.Count > 0 && componentStack.Peek().IsVirtualize) + { + if (invocation.Arguments.Length >= 2) + { + var nameArg = invocation.Arguments[1]; + if (nameArg.Value.ConstantValue.HasValue && + nameArg.Value.ConstantValue.Value is string paramName) + { + var state = componentStack.Peek(); + if (paramName == "ItemsProvider") + { + state.HasItemsProvider = true; + state.ItemsProviderLocation = invocation.Syntax.GetLocation(); + } + else if (paramName == "ItemComparer") + { + state.HasItemComparer = true; + } + } + } + } + break; + + case "CloseComponent": + if (componentStack.Count > 0) + { + var state = componentStack.Pop(); + if (state.IsVirtualize) + { + completedVirtualizeComponents.Add(state); + } + } + break; + } + }, OperationKind.Invocation); + + blockContext.RegisterOperationBlockEndAction(endContext => + { + foreach (var state in completedVirtualizeComponents) + { + if (state.HasItemsProvider && !state.HasItemComparer && state.ItemsProviderLocation != null) + { + endContext.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.VirtualizeItemsProviderRequiresItemComparer, + state.ItemsProviderLocation)); + } + } + }); + }); + }); + } + + private sealed class ComponentState + { + public bool IsVirtualize { get; set; } + public bool HasItemsProvider { get; set; } + public bool HasItemComparer { get; set; } + public Location? ItemsProviderLocation { get; set; } + } +} diff --git a/src/Components/Analyzers/test/VirtualizeItemComparerAnalyzerTest.cs b/src/Components/Analyzers/test/VirtualizeItemComparerAnalyzerTest.cs new file mode 100644 index 000000000000..14d810851578 --- /dev/null +++ b/src/Components/Analyzers/test/VirtualizeItemComparerAnalyzerTest.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using TestHelper; + +namespace Microsoft.AspNetCore.Components.Analyzers.Test; + +public class VirtualizeItemComparerAnalyzerTest : DiagnosticVerifier +{ + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() => new VirtualizeItemComparerAnalyzer(); + + private static readonly string VirtualizeDeclarations = @" + namespace Microsoft.AspNetCore.Components.Rendering + { + public class RenderTreeBuilder + { + public void OpenComponent(int sequence) where TComponent : IComponent { } + public void AddComponentParameter(int sequence, string name, object value) { } + public void CloseComponent() { } + } + } + + namespace Microsoft.AspNetCore.Components + { + public interface IComponent { } + } + + namespace Microsoft.AspNetCore.Components.Web.Virtualization + { + public class Virtualize : Microsoft.AspNetCore.Components.IComponent + { + public object ItemsProvider { get; set; } + public object Items { get; set; } + public object ItemComparer { get; set; } + public float ItemSize { get; set; } + } + + public delegate System.Threading.Tasks.ValueTask> ItemsProviderDelegate(ItemsProviderRequest request); + + public struct ItemsProviderRequest { } + public struct ItemsProviderResult { } + } +"; + + [Fact] + public void ItemsProviderWithoutItemComparer_ReportsDiagnostic() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.AspNetCore.Components.Rendering; + using Microsoft.AspNetCore.Components.Web.Virtualization; + + class TestComponent + { + void BuildRenderTree(RenderTreeBuilder __builder) + { + __builder.OpenComponent>(0); + __builder.AddComponentParameter(1, ""ItemsProvider"", (object)null); + __builder.AddComponentParameter(2, ""ItemSize"", (object)50f); + __builder.CloseComponent(); + } + } + }" + VirtualizeDeclarations; + + VerifyCSharpDiagnostic(test, + new DiagnosticResult + { + Id = DiagnosticDescriptors.VirtualizeItemsProviderRequiresItemComparer.Id, + Message = "Virtualize uses 'ItemsProvider' without 'ItemComparer'. Set ItemComparer to an IEqualityComparer that identifies items by a unique key.", + Severity = DiagnosticSeverity.Warning, + Locations = new[] + { + new DiagnosticResultLocation("Test0.cs", 12, 17) + } + }); + } + + [Fact] + public void ItemsProviderWithItemComparer_NoDiagnostic() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.AspNetCore.Components.Rendering; + using Microsoft.AspNetCore.Components.Web.Virtualization; + + class TestComponent + { + void BuildRenderTree(RenderTreeBuilder __builder) + { + __builder.OpenComponent>(0); + __builder.AddComponentParameter(1, ""ItemsProvider"", (object)null); + __builder.AddComponentParameter(2, ""ItemComparer"", (object)null); + __builder.CloseComponent(); + } + } + }" + VirtualizeDeclarations; + + VerifyCSharpDiagnostic(test); + } + + [Fact] + public void ItemsCollectionWithoutItemComparer_NoDiagnostic() + { + var test = @" + namespace ConsoleApplication1 + { + using Microsoft.AspNetCore.Components.Rendering; + using Microsoft.AspNetCore.Components.Web.Virtualization; + + class TestComponent + { + void BuildRenderTree(RenderTreeBuilder __builder) + { + __builder.OpenComponent>(0); + __builder.AddComponentParameter(1, ""Items"", (object)null); + __builder.CloseComponent(); + } + } + }" + VirtualizeDeclarations; + + VerifyCSharpDiagnostic(test); + } +} diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index cc6ed8873a41..703628420ce3 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -8,6 +8,8 @@ export const Virtualize = { dispose, scrollToBottom, refreshObservers, + setAnchorMode, + restoreAnchor, }; const dispatcherObserversByDotNetIdPropname = Symbol(); @@ -42,7 +44,7 @@ function getScaleFactor(spacerBefore: HTMLElement, spacerAfter: HTMLElement): nu return (Number.isFinite(scale) && scale > 0) ? scale : 1; } -function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50): void { +function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, anchorMode = 1, rootMargin = 50): void { // If the component was disposed before the JS interop call completed, the element references may be null // or the elements may have been disconnected from the DOM. Return early to avoid errors. if (!spacerBefore || !spacerAfter || !spacerBefore.isConnected || !spacerAfter.isConnected) { @@ -52,6 +54,12 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const scrollContainer = findClosestScrollContainer(spacerBefore); const scrollElement = scrollContainer || document.documentElement; const isTable = isValidTableElement(spacerAfter.parentElement); + + // Ensure the scroll container is focusable for Home/End key handling. + // Use tabindex="-1" so it's focusable via click/JS but not added to the tab order. + if (scrollContainer && !scrollContainer.hasAttribute('tabindex')) { + scrollContainer.setAttribute('tabindex', '-1'); + } const supportsAnchor = CSS.supports('overflow-anchor', 'auto'); const useNativeAnchoring = !isTable && supportsAnchor; @@ -85,6 +93,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const anchoredItems: Map = new Map(); let scrollTriggeredRender = false; + // After anchor restore, suppress spacer IO callbacks until the next user scroll. + let suppressSpacerCallbacks = false; + let ignoreAnchorScroll = false; + // Whether the viewport was at the bottom before the last render (for End-mode follow). + let wasAtBottom = false; + // Pending scroll correction after redistribution changes spacer→item heights. + let pendingScrollCorrection = false; + let scrollCorrectionItemIndex = 0; + let scrollCorrectionOffset = 0; + + function reobserveSpacers(): void { + intersectionObserver.unobserve(spacerBefore); + intersectionObserver.observe(spacerBefore); + intersectionObserver.unobserve(spacerAfter); + intersectionObserver.observe(spacerAfter); + } + function getObservedHeight(entry: ResizeObserverEntry): number { return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; } @@ -125,17 +150,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // 2. For convergence (sticky-top/bottom) - observes elements for geometry changes, drives the scroll position. // 3. Manual scroll compensation (tables/Safari) — adjusts scrollTop when above-viewport items resize. const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]): void => { - for (const entry of entries) { - if (entry.target === spacerBefore || entry.target === spacerAfter) { - const spacer = entry.target as HTMLElement; - if (spacer.isConnected) { - intersectionObserver.unobserve(spacer); - intersectionObserver.observe(spacer); - } - } - } - // Convergence logic: keep scroll pinned to top/bottom while items load. + // Do this before re-observing spacers so the IO callback sees the correct + // scroll position, not the stale one from before the spacer resize. if (convergingToBottom || convergingToTop) { scrollElement.scrollTop = convergingToBottom ? scrollElement.scrollHeight : 0; const spacer = convergingToBottom ? spacerAfter : spacerBefore; @@ -147,6 +164,16 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac stopConvergenceObserving(); } + for (const entry of entries) { + if (entry.target === spacerBefore || entry.target === spacerAfter) { + const spacer = entry.target as HTMLElement; + if (spacer.isConnected) { + intersectionObserver.unobserve(spacer); + intersectionObserver.observe(spacer); + } + } + } + // Manual scroll compensation: adjust scrollTop for above-viewport resizes. if (!useNativeAnchoring) { compensateScrollForItemResizes(entries); @@ -173,8 +200,15 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); - // During convergence, keep the observed element set in sync with the DOM. + // During convergence, keep the observed element set in sync with the DOM + // and force scroll position to prevent bounce-back between renders. if (convergingElements) { + if (convergingToBottom) { + scrollElement.scrollTop = scrollElement.scrollHeight; + } else if (convergingToTop) { + scrollElement.scrollTop = 0; + } + const currentItems: Set = new Set(); for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { resizeObserver.observe(el); @@ -209,8 +243,105 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } scrollTriggeredRender = false; - // Don't re-trigger IntersectionObserver here — ResizeObserver handles that - // when spacers actually resize. Doing it on every render causes feedback loops. + // End mode: scroll to bottom when items were appended while viewport was at bottom. + if ((anchorMode & 2) && wasAtBottom) { + scrollElement.scrollTop = scrollElement.scrollHeight; + ignoreAnchorScroll = true; + // Start convergence only when there are more items to load (spacerAfter > 0). + // When all items fit in DOM, the single scrollTop assignment above is sufficient. + if (!convergingToBottom && !convergingToTop && spacerAfter.offsetHeight > 0) { + convergingToBottom = true; + suppressSpacerCallbacks = false; + reobserveSpacers(); + startConvergenceObserving(); + } + } + + // Correct drift from spacer→item height differences after redistribution. + if (pendingScrollCorrection) { + let el: Element | null = spacerBefore.nextElementSibling; + for (let i = 0; i < scrollCorrectionItemIndex && el && el !== spacerAfter; i++) { + el = el.nextElementSibling; + } + if (el && el !== spacerAfter) { + pendingScrollCorrection = false; + const containerTop = scrollContainer ? scrollContainer.getBoundingClientRect().top : 0; + const delta = (el.getBoundingClientRect().top - containerTop) - scrollCorrectionOffset; + if (Math.abs(delta) > 1) { + scrollElement.scrollTop += delta; + ignoreAnchorScroll = true; + } + } + } + + // Capture the first visible item's position after each render. + updateAnchorSnapshot(); + + } + + // Corrects scrollTop after a render that shifted content, using the snapshot + // saved by updateAnchorSnapshot() during the previous render cycle. + function restoreAnchorForShift(): void { + const snapshot = observersByDotNetObjectId[id].anchorSnapshot; + if (!snapshot) { + return; + } + observersByDotNetObjectId[id].anchorSnapshot = null; + + if (convergingToTop || convergingToBottom) { + return; + } + + // Beginning mode at the very top: show new items by converging to top. + if ((anchorMode & 1) && snapshot.scrollTop < 1) { + convergingToTop = true; + scrollElement.scrollTop = 0; + startConvergenceObserving(); + return; + } + + let current = spacerBefore.nextElementSibling; + for (let i = 0; i < snapshot.anchorItemIndex && current && current !== spacerAfter; i++) { + current = current.nextElementSibling; + } + + if (!current || current === spacerAfter) { + 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. + suppressSpacerCallbacks = true; + ignoreAnchorScroll = true; + if (Math.abs(delta) > 1) { + scrollCorrectionItemIndex = snapshot.anchorItemIndex; + pendingScrollCorrection = true; + } + + // End mode: preserve wasAtBottom only if the viewport is actually at the bottom right now. + // Don't rely on the cached wasAtBottom — it may be stale if the user scrolled away. + const atBottom = scrollElement.scrollHeight <= scrollElement.clientHeight + || Math.abs(scrollElement.scrollTop + scrollElement.clientHeight - scrollElement.scrollHeight) < 2; + const preserveWasAtBottom = (anchorMode & 2) && atBottom; + + if (Math.abs(delta) > 1) { + scrollElement.scrollTop += delta; + } + + // Save anchor offset AFTER scrollTop adjustment for drift correction. + if (pendingScrollCorrection) { + const containerTop = scrollContainer ? scrollContainer.getBoundingClientRect().top : 0; + scrollCorrectionOffset = current.getBoundingClientRect().top - containerTop; + } + + if (preserveWasAtBottom) { + wasAtBottom = true; + } } function startConvergenceObserving(): void { @@ -236,6 +367,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement.style.overflowAnchor = ''; } anchoredItems.clear(); + // Take a fresh snapshot so the next anchor restore has valid data. + updateAnchorSnapshot(); } let convergingToBottom = false; @@ -248,6 +381,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac function handleJumpKeys(e: Event): void { const ke = e as KeyboardEvent; if (ke.key === 'End') { + suppressSpacerCallbacks = false; + reobserveSpacers(); pendingJumpToEnd = true; pendingJumpToStart = false; if (!convergingToBottom && spacerAfter.offsetHeight > 0) { @@ -255,6 +390,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac startConvergenceObserving(); } } else if (ke.key === 'Home') { + suppressSpacerCallbacks = false; + reobserveSpacers(); pendingJumpToStart = true; pendingJumpToEnd = false; if (!convergingToTop && spacerBefore.offsetHeight > 0) { @@ -265,6 +402,27 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } keydownTarget.addEventListener('keydown', handleJumpKeys); + const scrollEventTarget: EventTarget = scrollContainer ?? window; + function handleScroll(): void { + if (convergingToBottom || convergingToTop) { + return; + } + + if (ignoreAnchorScroll) { + ignoreAnchorScroll = false; + return; + } + + // Clear suppression and re-observe on user scroll. + if (suppressSpacerCallbacks) { + suppressSpacerCallbacks = false; + reobserveSpacers(); + } + + updateAnchorSnapshot(); + } + scrollEventTarget.addEventListener('scroll', handleScroll, { passive: true }); + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); let pendingCallbacks: Map = new Map(); let callbackTimeout: ReturnType | null = null; @@ -276,11 +434,15 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement, startConvergenceObserving, setConvergingToBottom: () => { convergingToBottom = true; }, + setAnchorMode: (mode: number) => { anchorMode = mode; }, + restoreAnchor: restoreAnchorForShift, + anchorSnapshot: null as { anchorItemIndex: number; anchorOffset: number; scrollTop: number } | null, onDispose: () => { stopConvergenceObserving(); anchoredItems.clear(); resizeObserver.disconnect(); keydownTarget.removeEventListener('keydown', handleJumpKeys); + scrollEventTarget.removeEventListener('scroll', handleScroll); if (callbackTimeout) { clearTimeout(callbackTimeout); callbackTimeout = null; @@ -319,15 +481,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } if (convergingToBottom) return; - const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; - if (!atBottom && !pendingJumpToEnd) return; - - convergingToBottom = true; - startConvergenceObserving(); + // pendingJumpToEnd is user-initiated (End key) — always honor it. + // Data-driven convergence only fires when End anchoring is enabled. if (pendingJumpToEnd) { + convergingToBottom = true; + startConvergenceObserving(); scrollElement.scrollTop = scrollElement.scrollHeight; pendingJumpToEnd = false; + return; } + + if (!(anchorMode & 2)) return; + + const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; + if (!atBottom) return; + + convergingToBottom = true; + startConvergenceObserving(); } function onSpacerBeforeVisible(): void { @@ -340,15 +510,50 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } if (convergingToTop) return; + // pendingJumpToStart is user-initiated (Home key) — always honor it. + // Data-driven convergence only fires when Beginning anchoring is enabled. + if (pendingJumpToStart) { + convergingToTop = true; + startConvergenceObserving(); + scrollElement.scrollTop = 0; + pendingJumpToStart = false; + return; + } + + if (!(anchorMode & 1)) return; + const atTop = scrollElement.scrollTop < 1; - if (!atTop && !pendingJumpToStart) return; + if (!atTop) return; convergingToTop = true; startConvergenceObserving(); - if (pendingJumpToStart) { - scrollElement.scrollTop = 0; - pendingJumpToStart = false; + } + + // Saves the first visible item's child index and viewport-relative position. + function updateAnchorSnapshot(): void { + wasAtBottom = scrollElement.scrollHeight <= scrollElement.clientHeight + || Math.abs(scrollElement.scrollTop + scrollElement.clientHeight - scrollElement.scrollHeight) < 2; + + const containerTop = scrollContainer + ? scrollContainer.getBoundingClientRect().top + : 0; + + let anchorItemIndex = 0; + for (let el = spacerBefore.nextElementSibling; + el && el !== spacerAfter; + el = el.nextElementSibling) { + const rect = el.getBoundingClientRect(); + if (rect.bottom > containerTop) { + observersByDotNetObjectId[id].anchorSnapshot = { + anchorItemIndex, + anchorOffset: rect.top - containerTop, + scrollTop: scrollElement.scrollTop, + }; + return; + } + anchorItemIndex++; } + observersByDotNetObjectId[id].anchorSnapshot = null; } function processIntersectionEntries(entries: IntersectionObserverEntry[]): void { @@ -357,7 +562,20 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return; } + // Keep the anchor snapshot fresh on every IO callback so it reflects + // the current scroll position, not just the last render. Skip when + // suppression is active — those callbacks have pre-restore stale data. + if (!suppressSpacerCallbacks) { + updateAnchorSnapshot(); + } + const intersectingEntries = entries.filter(entry => { + // After an anchor restore, skip ALL spacer callbacks until the user + // scrolls. Re-observation is handled in handleScroll. + if (suppressSpacerCallbacks && (entry.target === spacerBefore || entry.target === spacerAfter)) { + return false; + } + if (entry.isIntersecting) { if (entry.target === spacerAfter) { onSpacerAfterVisible(); @@ -429,6 +647,18 @@ function refreshObservers(dotNetHelper: DotNet.DotNetObject): void { entry?.refreshObservedElements?.(); } +function setAnchorMode(dotNetHelper: DotNet.DotNetObject, mode: number): void { + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + const entry = observersByDotNetObjectId[id]; + entry?.setAnchorMode?.(mode); +} + +function restoreAnchor(dotNetHelper: DotNet.DotNetObject): void { + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + const entry = observersByDotNetObjectId[id]; + entry?.restoreAnchor?.(); +} + function getObserversMapEntry(dotNetHelper: DotNet.DotNetObject): { observersByDotNetObjectId: {[id: number]: any }, id: number } { const dotNetHelperDispatcher = dotNetHelper['_callDispatcher']; const dotNetHelperId = dotNetHelper['_id']; @@ -456,3 +686,5 @@ function dispose(dotNetHelper: DotNet.DotNetObject): void { // even if init() returned early and no observers were created. dotNetHelper.dispose(); } + + diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts index 318063225786..27039e90e73b 100644 --- a/src/Components/Web.JS/test/Virtualize.test.ts +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -7,5 +7,7 @@ describe('Virtualize exports', () => { expect(typeof Virtualize.dispose).toBe('function'); expect(typeof Virtualize.scrollToBottom).toBe('function'); expect(typeof Virtualize.refreshObservers).toBe('function'); + expect(typeof Virtualize.setAnchorMode).toBe('function'); + expect(typeof Virtualize.restoreAnchor).toBe('function'); }); }); diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 3e6daf94f47d..06b4ed602cdd 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -84,6 +84,14 @@ Microsoft.AspNetCore.Components.Web.SupplyParameterFromTempDataAttribute Microsoft.AspNetCore.Components.Web.SupplyParameterFromTempDataAttribute.Name.get -> string? Microsoft.AspNetCore.Components.Web.SupplyParameterFromTempDataAttribute.Name.set -> void Microsoft.AspNetCore.Components.Web.SupplyParameterFromTempDataAttribute.SupplyParameterFromTempDataAttribute() -> 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.ItemComparer.get -> System.Collections.Generic.IEqualityComparer! +Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize.ItemComparer.set -> void +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 +Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.None = 0 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode virtual Microsoft.AspNetCore.Components.Forms.InputFile.Dispose(bool disposing) -> void Microsoft.AspNetCore.Components.Forms.DisplayName Microsoft.AspNetCore.Components.Forms.DisplayName.DisplayName() -> void diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index a90372571ca3..37d9cbd93ba0 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -21,7 +21,7 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private ElementReference _spacerAfter; - private int _itemsBefore; + internal int _itemsBefore; private int _visibleItemCapacity; @@ -47,9 +47,10 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private IEnumerable? _loadedItems; - // For in-memory Items where objects have stable identity private TItem? _previousFirstLoadedItem; + private bool _itemComparerExplicitlySet; + private CancellationTokenSource? _refreshCts; private bool _skipNextDistributionRefresh; @@ -72,6 +73,12 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I internal bool _pendingScrollToBottom; + private VirtualizeAnchorMode _lastRenderedAnchorMode; + + // When true, OnAfterRenderAsync tells JS to restore the anchor snapshot + // so the viewport stays stable after a prepend or append. + private bool _pendingAnchorRestore; + [Inject] private IJSRuntime JSRuntime { get; set; } = default!; @@ -150,6 +157,44 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I [Parameter] public int MaxItemCount { get; set; } = 100; + /// + /// Gets or sets the anchor mode that controls how the viewport behaves at the edges + /// of the list when new items arrive. The default is . + /// + [Parameter] + public VirtualizeAnchorMode AnchorMode { get; set; } = VirtualizeAnchorMode.Beginning; + + /// + /// Gets or sets a comparer used to detect whether items were prepended or appended + /// when using . The comparer determines if the first loaded + /// item changed between provider calls, which indicates items were inserted above. + /// + /// Defaults to . For records and types implementing + /// , the default works automatically (value equality). For classes + /// without value-equality semantics, provide a comparer that compares by a unique identifier + /// (e.g., Id); otherwise reference-equality fallback would produce false-positive + /// prepend detection when the provider returns fresh instances. + /// + /// Prepend detection only runs when this parameter is explicitly assigned by the consumer. + /// The BL0011 analyzer warns when is used without an + /// explicit assignment. + /// + /// For in-memory , this parameter is not needed because the component + /// can detect prepends using object identity. + /// + [Parameter] + public IEqualityComparer ItemComparer + { + get => _itemComparer; + set + { + _itemComparer = value; + _itemComparerExplicitlySet = true; + } + } + + private IEqualityComparer _itemComparer = EqualityComparer.Default; + /// /// Instructs the component to re-request data from its . /// This is useful if external data may have changed. There is no need to call this @@ -230,7 +275,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { _jsInterop = new VirtualizeJsInterop(this, JSRuntime); - await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter); + await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter, (int)AnchorMode); + _lastRenderedAnchorMode = AnchorMode; } if (_pendingScrollToBottom && _jsInterop is not null) @@ -242,6 +288,22 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // After render the set of items could change. Tell JS to refresh ResizeObserver. if (!firstRender && _jsInterop is not null) { + if (_lastRenderedAnchorMode != AnchorMode) + { + _lastRenderedAnchorMode = AnchorMode; + 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; + _pendingAnchorRestore = false; + + if (shouldRestore) + { + await _jsInterop.RestoreAnchorAsync(); + } + await _jsInterop.RefreshObserversAsync(); } } @@ -293,10 +355,17 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenRegion(5); + var isFirstRenderedItem = true; foreach (var item in itemsToShow) { _itemTemplate(item)(builder); _lastRenderedItemCount++; + + if (isFirstRenderedItem && _itemComparerExplicitlySet && _itemsProvider != DefaultItemsProvider) + { + _previousFirstLoadedItem = item; + isFirstRenderedItem = false; + } } renderIndex += _lastRenderedItemCount; @@ -364,6 +433,11 @@ private bool ProcessMeasurements(float spacerSeparation) void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) { + if (_pendingAnchorRestore) + { + return; + } + ProcessMeasurements(spacerSeparation); CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity); @@ -379,6 +453,11 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) { + if (_pendingAnchorRestore) + { + return; + } + var hadNewMeasurements = ProcessMeasurements(spacerSeparation); CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity); @@ -391,11 +470,15 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS itemsBefore++; } - // When we're at the very bottom and new measurements arrived, - // scroll to bottom so the viewport stays pinned while items converge. + // Track whether the viewport is at the bottom of the list. + // In End mode, keep scrolling to bottom while measurements converge. if (itemsAfter == 0 && hadNewMeasurements) { - _pendingScrollToBottom = true; + if ((AnchorMode & VirtualizeAnchorMode.End) != 0) + { + _pendingScrollToBottom = true; + _pendingAnchorRestore = false; + } } UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); @@ -520,18 +603,46 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess) { var previousItemCount = _itemCount; var countDelta = result.TotalItemCount - previousItemCount; + var itemsAdded = countDelta > 0 && previousItemCount > 0; + var isDefaultProvider = _itemsProvider == DefaultItemsProvider; - // Detect if items were prepended above the current viewport position. - if (countDelta > 0 && _itemsBefore > 0 && _previousFirstLoadedItem != null - && _itemsProvider == DefaultItemsProvider) + if (itemsAdded && isDefaultProvider && _previousFirstLoadedItem != null) { var newFirstItem = Items!.ElementAtOrDefault(_itemsBefore); - if (newFirstItem != null && !ReferenceEquals(_previousFirstLoadedItem, newFirstItem)) + // Use EqualityComparer.Default so this works for value-type TItem; + // ReferenceEquals would always return false due to boxing. + if (newFirstItem != null && !EqualityComparer.Default.Equals(_previousFirstLoadedItem, newFirstItem)) { - _itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity)); - - var adjustedRequest = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken); - result = await _itemsProvider(adjustedRequest); + result = await AdjustForPrependAsync(countDelta, result.TotalItemCount, cancellationToken); + } + else if (ShouldAnchorForAppend(countDelta, previousItemCount)) + { + _pendingAnchorRestore = true; + } + else if (ShouldScrollToBottomForAppend(countDelta, previousItemCount)) + { + _pendingScrollToBottom = true; + } + } + else if (itemsAdded && !isDefaultProvider && _itemComparerExplicitlySet && _previousFirstLoadedItem != null) + { + using var enumerator = result.Items.GetEnumerator(); + if (enumerator.MoveNext()) + { + var itemsShifted = !ItemComparer.Equals(_previousFirstLoadedItem, enumerator.Current); + + if (itemsShifted) + { + result = await AdjustForPrependAsync(countDelta, result.TotalItemCount, cancellationToken); + } + else if (ShouldAnchorForAppend(countDelta, previousItemCount)) + { + _pendingAnchorRestore = true; + } + else if (ShouldScrollToBottomForAppend(countDelta, previousItemCount)) + { + _pendingScrollToBottom = true; + } } } @@ -539,12 +650,17 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess) _loadedItems = result.Items; _loadedItemsStartIndex = _itemsBefore; - // Only needed for DefaultItemsProvider; custom providers return new instances - // per request, making ReferenceEquals unreliable. - _previousFirstLoadedItem = _itemsProvider == DefaultItemsProvider - && Items != null && _itemsBefore < Items.Count - ? Items.ElementAtOrDefault(_itemsBefore) - : default; + // For DefaultItemsProvider, capture the first loaded item so we can detect + // prepends via EqualityComparer.Default (works for both reference and + // value types — see comment on the comparison above). + // For custom providers, _previousFirstLoadedItem is set during BuildRenderTree + // (using the actual rendered item for ItemComparer). + if (_itemsProvider == DefaultItemsProvider) + { + _previousFirstLoadedItem = Items != null && _itemsBefore < Items.Count + ? Items.ElementAtOrDefault(_itemsBefore) + : default; + } _loading = false; _skipNextDistributionRefresh = request.Count > 0; @@ -586,6 +702,29 @@ private RenderFragment DefaultPlaceholder(PlaceholderContext context) => (builde builder.CloseElement(); }; + private async ValueTask> AdjustForPrependAsync( + int countDelta, int newTotalCount, CancellationToken cancellationToken) + { + _itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, newTotalCount - _visibleItemCapacity)); + _pendingAnchorRestore = true; + + var adjustedRequest = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken); + return await _itemsProvider(adjustedRequest); + } + + // Items appended at the bottom while viewport is near the end. + // In non-End modes, restore the anchor so the viewport doesn't + // chase the new items via spacer redistribution. + private bool ShouldAnchorForAppend(int countDelta, int previousItemCount) + => countDelta > 0 + && (AnchorMode & VirtualizeAnchorMode.End) == 0 + && _itemsBefore + _visibleItemCapacity >= previousItemCount; + + private bool ShouldScrollToBottomForAppend(int countDelta, int previousItemCount) + => countDelta > 0 + && (AnchorMode & VirtualizeAnchorMode.End) != 0 + && previousItemCount <= _visibleItemCapacity; + /// public async ValueTask DisposeAsync() { diff --git a/src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs b/src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs new file mode 100644 index 000000000000..298ffd84798a --- /dev/null +++ b/src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs @@ -0,0 +1,34 @@ +// 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; + +/// +/// Controls how the viewport behaves at the edges of the list when +/// new items arrive. Flags can be combined to pin both edges. +/// +[Flags] +public enum VirtualizeAnchorMode +{ + /// + /// No edge pinning. The viewport stays at its current scroll position + /// regardless of item changes. + /// + None = 0, + + /// + /// Pins the viewport to the beginning of the list. When the user is + /// at or near the top and new items arrive at the beginning, the viewport + /// stays at the top showing the newest items — matching standard news + /// feed / notification list UX. + /// + Beginning = 1, + + /// + /// Pins the viewport to the end of the list. When the user is at or near + /// the bottom and new items arrive at the end, the viewport auto-scrolls + /// to show them. If the user has scrolled away, auto-scroll disengages + /// until they return to the bottom — matching standard chat / log UX. + /// + End = 2, +} diff --git a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs index 363557cb7688..4503d31434f1 100644 --- a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs +++ b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs @@ -24,10 +24,10 @@ public VirtualizeJsInterop(IVirtualizeJsCallbacks owner, IJSRuntime jsRuntime) _jsRuntime = jsRuntime; } - public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter) + public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter, int anchorMode) { _selfReference = DotNetObjectReference.Create(this); - await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter); + await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter, anchorMode); } [JSInvokable] @@ -52,6 +52,16 @@ public ValueTask RefreshObserversAsync() return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.refreshObservers", _selfReference); } + public ValueTask SetAnchorModeAsync(int anchorMode) + { + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.setAnchorMode", _selfReference, anchorMode); + } + + public ValueTask RestoreAnchorAsync() + { + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.restoreAnchor", _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 f0435bdbb2c1..17d0b176378d 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -443,7 +443,8 @@ public async Task Virtualize_ScrollToBottom_SetWhenAtEndWithNewMeasurements() var rootComponent = new VirtualizeTestHostcomponent { InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(), - captureRenderedVirtualize: v => renderedVirtualize = v) + captureRenderedVirtualize: v => renderedVirtualize = v, + anchorMode: VirtualizeAnchorMode.End) }; var serviceProvider = new ServiceCollection() @@ -783,7 +784,8 @@ private RenderFragment BuildVirtualizeWithContent( float itemSize, ICollection items, Action> captureRenderedVirtualize = null, - string spacerElement = "div") + string spacerElement = "div", + VirtualizeAnchorMode anchorMode = VirtualizeAnchorMode.Beginning) => builder => { builder.OpenComponent>(0); @@ -796,10 +798,11 @@ private RenderFragment BuildVirtualizeWithContent( b.AddContent(1, item.ToString(System.Globalization.CultureInfo.InvariantCulture)); b.CloseElement(); })); + builder.AddComponentParameter(5, "AnchorMode", anchorMode); if (captureRenderedVirtualize != null) { - builder.AddComponentReferenceCapture(5, component => + builder.AddComponentReferenceCapture(6, component => captureRenderedVirtualize(component as Virtualize)); } @@ -867,4 +870,97 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.CloseElement(); } } + + [Fact] + public async Task Virtualize_ItemsProvider_GrowingTotalCount_DoesNotAssumePrepend() + { + Virtualize renderedVirtualize = null; + var totalCount = 100; + + ValueTask> growingProvider(ItemsProviderRequest request) + { + var items = Enumerable.Range(request.StartIndex, Math.Min(request.Count, totalCount - request.StartIndex)); + return ValueTask.FromResult(new ItemsProviderResult(items, totalCount)); + } + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualize(50f, (ItemsProviderDelegate)growingProvider, null, 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); + + var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; + + // Initial IO callback to set up _itemCount + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 800f, 800f)); + + var itemsBeforeAfterInit = renderedVirtualize._itemsBefore; + + // Simulate TotalItemCount growing (new data arriving, not a prepend) + totalCount = 120; + + // IO-driven refresh (NOT RefreshDataAsync) — triggered by spacer becoming visible + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 800f, 800f)); + + // _itemsBefore may change due to normal IO redistribution, but should NOT + // have been shifted by exactly countDelta (20) which would indicate false + // prepend detection. Normal redistribution produces different offsets. + var shift = renderedVirtualize._itemsBefore - itemsBeforeAfterInit; + Assert.True(shift != 20, + $"IO-driven refresh should not trigger prepend detection (shift by countDelta). " + + $"Before: {itemsBeforeAfterInit}, After: {renderedVirtualize._itemsBefore}, Shift: {shift}"); + } + + [Fact] + public async Task Virtualize_DefaultProvider_ValueTypeItem_AppendDoesNotAssumePrepend() + { + Virtualize renderedVirtualize = null; + var items = Enumerable.Range(1, 100).ToList(); + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, items, 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); + + var callbacks = (IVirtualizeJsCallbacks)renderedVirtualize; + + // Initial IO callback to set up _itemCount. + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 800f, 800f)); + + var itemsBeforeAfterInit = renderedVirtualize._itemsBefore; + + // Append 20 items at the end. Items[0] is unchanged — this is NOT a prepend. + items.AddRange(Enumerable.Range(101, 20)); + + // IO-driven refresh re-reads the in-memory list and observes count growth 100 -> 120. + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 800f, 800f)); + + var shift = renderedVirtualize._itemsBefore - itemsBeforeAfterInit; + Assert.True(shift != 20, + $"In-memory append on value-type TItem must not trigger prepend detection (shift by countDelta). " + + $"Before: {itemsBeforeAfterInit}, After: {renderedVirtualize._itemsBefore}, Shift: {shift}"); + } } diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index bd4731766c36..c97154c04d8c 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1035,9 +1035,9 @@ public void DynamicContent_ItemHeightChangesUpdateLayout() $"Item 3 should have moved down after item 2 expanded. Before: {item3TopBefore}, After: {item3TopAfter}"); js.ExecuteScript("arguments[0].scrollTop = 200", container); - Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 200); + AssertScrollTop(js, container, st => st >= 200, "scrollTop >= 200"); js.ExecuteScript("arguments[0].scrollTop = 0", container); - Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) == 0); + AssertScrollTop(js, container, st => st == 0, "scrollTop == 0"); // Item 2 should still be expanded after scrolling item2 = container.FindElement(By.CssSelector("[data-index='2']")); @@ -1054,15 +1054,16 @@ public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() var status = Browser.Exists(By.Id("status")); Browser.True(() => GetElementCount(container, ".item") > 0); - // Scroll to the very bottom so item 2 is virtualized out of the DOM - js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + // Scroll to the very bottom so item 2 is virtualized out of the DOM. + // Retry since spacer recalculation may shift scrollTop as items are measured. Browser.True(() => { + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + ScrollContainer(js, container, (int)sh); var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); - var scrollHeight = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); var clientHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); - return scrollTop >= scrollHeight - clientHeight - 1; - }); + return scrollTop >= sh - clientHeight - 1; + }, TimeSpan.FromSeconds(10)); // Wait for virtualization to converge after scrolling to bottom Browser.True(() => container.FindElements(By.CssSelector("[data-index='2']")).Count == 0, @@ -1132,7 +1133,7 @@ public virtual void DynamicContent_PrependItemsWhileScrolledToMiddle_VisibleItem // Verify prepended items are reachable at the top. js.ExecuteScript("arguments[0].scrollTop = 0", container); - Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) == 0); + AssertScrollTop(js, container, st => st == 0, "scrollTop == 0"); Browser.True(() => container.FindElements(By.CssSelector("[data-index='-10']")).Count > 0); var topItems = container.FindElements(By.CssSelector(".item")); Assert.True(topItems.Count > 0, "Should render items after scrolling back to top."); @@ -1171,6 +1172,73 @@ public void DynamicContent_AppendItemsWhileScrolledToMiddle_VisibleItemsStayInPl $"Before: {scrollTopBefore}, After: {scrollTopAfter}"); } + [Fact] + public void DynamicContent_PrependItemsWhileAtTop_NewItemsAppearAtTop() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Verify we're at the top. + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // At scrollTop=0, the natural floor prevents native anchoring from compensating — new items + // appear at the top and old items shift down. This is the default AnchorMode.Beginning behavior. + // In contrast, AnchorMode.None compensates scrollTop so old items stay in view. + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.True(scrollTopAfter < 50, + $"scrollTop should stay near 0 at the natural floor, but was {scrollTopAfter}"); + + // The prepended items should become visible after the IO callback triggers re-render. + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-10']")).Count > 0); + } + + [Fact] + public void DynamicContent_AppendItemsWhileAtBottom_ViewportStaysStable() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll to the bottom edge. + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + Browser.True(() => + { + var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var scrollHeight = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var clientHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return scrollHeight - scrollTop - clientHeight < 2; + }); + + var scrollTopBefore = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Default AnchorMode is Beginning, which only pins the top edge. + // The viewport should NOT converge to the new bottom. With a small + // append (10 items × 50px = 500px), scrollTop should not increase + // significantly. Allow tolerance for spacer recalculation settling. + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var scrollHeightAfter = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var clientHeightAfter = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + var gapAfter = scrollHeightAfter - scrollTopAfter - clientHeightAfter; + + // If convergence were active, gap would be ~0 (chased to bottom). + // Without convergence, the 500px of new items creates a visible gap. + Assert.True(gapAfter > 50, + $"Default Beginning mode: should not converge to bottom after small append. " + + $"scrollTop before: {scrollTopBefore}, after: {scrollTopAfter}, " + + $"scrollHeight: {scrollHeightAfter}, gap: {gapAfter}"); + } + [Fact] public void VariableHeight_ContainerResizeWorks() { @@ -1825,7 +1893,7 @@ public void ViewportAnchoring_ExpandAboveViewport_VisibleItemStaysInPlace() // Scroll down so item 5 is above the viewport but still in DOM and verify its position js.ExecuteScript("arguments[0].scrollTop = 500", container); - Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 500); + AssertScrollTop(js, container, st => st >= 500, "scrollTop >= 500"); Browser.True(() => container.FindElements(By.CssSelector("[data-index='5']")).Count > 0); // Record the first visible item and its position relative to the container. @@ -1862,7 +1930,7 @@ public void ViewportAnchoring_CollapseAboveViewport_VisibleItemStaysInPlace() // Scroll down past item 5 so it's in overscan above viewport js.ExecuteScript("arguments[0].scrollTop = 600", container); - Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 600); + AssertScrollTop(js, container, st => st >= 600, "scrollTop >= 600"); // Record first visible item position relative to container var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); @@ -1910,34 +1978,1670 @@ public void Table_VariableHeight_IncrementalScrollDoesNotCauseWildJumps() $"Large jumps indicate CSS scroll anchoring is miscalculating on elements."); } - private static (string index, double relTop, long scrollTop) GetItemPositionInContainer( - IJavaScriptExecutor js, IWebElement container, string itemSelector, string dataIndex = null) + private void MountAnchorModeComponent(string anchorMode, bool variableHeight = false, bool useItemsProvider = false) { - var result = js.ExecuteScript(@" - var container = arguments[0]; - var selector = arguments[1]; - var targetIndex = arguments[2]; - var containerRect = container.getBoundingClientRect(); - var items = container.querySelectorAll(selector); - for (var i = 0; i < items.length; i++) { - var item = items[i]; - var itemRect = item.getBoundingClientRect(); - if (targetIndex != null) { - if (item.getAttribute('data-index') === targetIndex) { - return { index: targetIndex, relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("scroll-container")); + Browser.True(() => GetElementCount(container, ".item") > 0); + + if (useItemsProvider) + { + 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); + } + + var select = Browser.Exists(By.Id("anchor-mode-select")); + var selectElement = new SelectElement(select); + selectElement.SelectByValue(anchorMode); + + Browser.True(() => Browser.Exists(By.Id("current-mode")).Text == anchorMode); + Browser.True(() => GetElementCount(container, ".item") > 0); + } + + private static void ScrollContainer(IJavaScriptExecutor js, IWebElement container, int scrollTop) + { + js.ExecuteScript(@" + var el = arguments[0]; + el.scrollTop = arguments[1]; + el.dispatchEvent(new Event('scroll')); + ", container, scrollTop); + } + + private void AssertScrollTop(IJavaScriptExecutor js, IWebElement container, Func condition, string expectation) + { + long st = 0, sh = 0, ch = 0; + try + { + Browser.True(() => + { + st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return condition(st); + }); + } + catch (Exception ex) + { + throw new Exception( + $"Scroll assertion failed: expected {expectation}, " + + $"but scrollTop={st}, scrollHeight={sh}, clientHeight={ch}, maxScrollTop={sh - ch}", ex); + } + } + + // Repeatedly issues `scroll` until the resulting scrollTop satisfies `condition`. + // Used for test setup where the browser may silently clamp scrollTop (e.g. before + // Virtualize has sized the spacer to make the target reachable). Do NOT use this + // when the test's purpose is to verify that a single scroll command took effect — + // use AssertScrollTop instead, which polls the position without re-issuing scrolls. + private void ScrollUntil(IJavaScriptExecutor js, IWebElement container, Action scroll, Func condition, string expectation) + { + long st = 0, sh = 0, ch = 0; + try + { + Browser.True(() => + { + scroll(); + st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return condition(st); + }); + } + catch (Exception ex) + { + throw new Exception( + $"Scroll assertion failed: expected {expectation}, " + + $"but scrollTop={st}, scrollHeight={sh}, clientHeight={ch}, maxScrollTop={sh - ch}", ex); + } + } + + private void ScrollToBottomAndWait(IWebElement container, IJavaScriptExecutor js) + { + Browser.True(() => + { + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + ScrollContainer(js, container, (int)sh); + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return sh - st - ch < 2; + }, TimeSpan.FromSeconds(10)); + + // Wait for Virtualize to render actual items visible in the viewport. + // On Blazor Server, items may not appear immediately after scroll + // convergence due to SignalR round-trip latency. + Browser.True(() => + { + var found = js.ExecuteScript(@" + var c = arguments[0]; + var cr = c.getBoundingClientRect(); + var items = c.querySelectorAll('.item[data-index]'); + for (var i = 0; i < items.length; i++) { + var ir = items[i].getBoundingClientRect(); + if (ir.bottom > cr.top + 1 && ir.top < cr.bottom - 1) return true; + } + return false; + ", container); + return found is bool b && b; + }, TimeSpan.FromSeconds(5), "Visible items should be rendered after scrolling to bottom"); + } + + private void ScrollMidListAndWaitForRender(IWebElement container, IJavaScriptExecutor js) + { + ScrollUntil(js, container, () => ScrollContainer(js, container, 5000), + st => st > 4000, "scrollTop > 4000 after ScrollContainer(5000)"); + // Wait for Virtualize to render items at the new scroll position. + Browser.True(() => + { + var result = js.ExecuteScript(@" + var container = arguments[0]; + var containerRect = container.getBoundingClientRect(); + var items = container.querySelectorAll('.item[data-index]'); + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var idx = parseInt(item.getAttribute('data-index'), 10); + var itemRect = item.getBoundingClientRect(); + + if (!Number.isNaN(idx) + && idx > 20 + && itemRect.bottom > containerRect.top + 1 + && itemRect.top < containerRect.bottom - 1) { + return true; } - } else if (itemRect.top >= containerRect.top - 1 && itemRect.top < containerRect.bottom) { - return { index: item.getAttribute('data-index'), relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; } - } - return null; - ", container, itemSelector, dataIndex) as Dictionary; - Assert.NotNull(result); - return ( - result["index"].ToString(), - Convert.ToDouble(result["relTop"], CultureInfo.InvariantCulture), - Convert.ToInt64(result["scrollTop"], CultureInfo.InvariantCulture)); + return false; + ", container); + + return result is bool isVisible && isVisible; + }); + } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_None_PrependAtTop_ViewportStaysStable(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("0", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "None mode at top: same item should stay at same position after prepend", + driftTolerance: 2); + + // Scroll up and verify prepended items are actually reachable. + ScrollContainer(js, container, 0); + Browser.True(() => + { + return container.FindElements(By.CssSelector("[data-index='-10']")).Count > 0; + }, TimeSpan.FromSeconds(5), "None mode: prepended items should be reachable after scrolling up"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_None_LargeAppendAtBottom_DoesNotFollowToBottom(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("0", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + // The viewport should NOT end up at the very bottom of 600 items. + var st2 = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh2 = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch2 = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + var gap = sh2 - st2 - ch2; + Assert.True(gap > 2000, + $"None mode: should not converge to bottom after large append. " + + $"scrollTop: {st2}, scrollHeight: {sh2}, gap: {gap}"); + } + + // Append at bottom in None mode: the viewport should not auto-scroll to follow + // appended content. Pixel-perfect stability requires height cache (Option B); + // for now we verify the viewport doesn't jump to the very bottom. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_None_AppendAtBottom_NoAutoScroll(bool useItemsProvider) + { + MountAnchorModeComponent("0", useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "None mode at bottom: same item should stay at same position after small append", + driftTolerance: 2); + } + + private void ScrollNearTopAndWaitForRender(IWebElement container, IJavaScriptExecutor js) + { + ScrollUntil(js, container, () => ScrollContainer(js, container, 200), + st => st >= 150, "scrollTop >= 150 after ScrollContainer(200)"); + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".item[data-index]")); + return items.Count > 0; + }); + } + + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_NearTop_PrependKeepsViewportStable(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollNearTopAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + $"AnchorMode {anchorMode} near top: viewport should stay stable after prepend", + driftTolerance: 2); + } + + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_NearTop_AppendKeepsViewportStable(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollNearTopAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + $"AnchorMode {anchorMode} near top: viewport should stay stable after append", + driftTolerance: 2); + } + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_Top_AppendKeepsViewportStable(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.True((long)js.ExecuteScript("return arguments[0].scrollTop", container) < 2); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + $"AnchorMode {anchorMode} at top: viewport should stay stable after append", + driftTolerance: 2); + } + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_Bottom_PrependKeepsViewportStable(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + $"AnchorMode {anchorMode} at bottom: viewport should stay stable after prepend", + driftTolerance: 2); + } + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("0", true)] + [InlineData("1", true)] + public void AnchorMode_MidList_AppendKeepsViewportStable(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + $"AnchorMode {anchorMode} mid-list: viewport should stay stable after append", + driftTolerance: 2); + } + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_EndKeyJumpsToBottom(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.True((long)js.ExecuteScript("return arguments[0].scrollTop", container) < 50); + + // End key should always work regardless of anchor mode. + container.SendKeys(Keys.End); + + Browser.True(() => + { + var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var scrollHeight = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var clientHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return scrollHeight - scrollTop - clientHeight < 2; + }, TimeSpan.FromSeconds(10), $"AnchorMode {anchorMode}: End key should jump to the bottom of the list"); + } + + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_HomeKeyJumpsToTop(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // Scroll to mid-list first. + ScrollMidListAndWaitForRender(container, js); + + // Home key should always work regardless of anchor mode. + container.SendKeys(Keys.Home); + + Browser.True(() => + { + var scrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + return scrollTop < 2; + }, TimeSpan.FromSeconds(10), $"AnchorMode {anchorMode}: Home key should jump to the top of the list"); + + Browser.True(() => container.FindElements(By.CssSelector("[data-index='0']")).Count > 0, + $"AnchorMode {anchorMode}: item 0 should be visible after Home key"); + } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_None_MidList_ViewportStable(bool useItemsProvider) + { + MountAnchorModeComponent("0", useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "None mode mid-list: viewport should stay visually stable after prepend", + compareWholePixels: true); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_None_ItemExpansionAfterPrepend_NoGap(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("0", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Wait for anchor restore to settle before expanding an item. + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "None mode: viewport should stay stable after prepend", + driftTolerance: 5); + + Browser.Exists(By.Id("expand-item")).Click(); + Browser.Contains("Expanded item", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "None mode: viewport should stay stable after prepend + item expansion", + driftTolerance: 5); + + // No visible gaps between rendered items + var hasGap = js.ExecuteScript(@" + var c = arguments[0]; + var items = c.querySelectorAll('.item[data-index]'); + var cr = c.getBoundingClientRect(); + for (var i = 0; i < items.length - 1; i++) { + var bottom = items[i].getBoundingClientRect().bottom; + var nextTop = items[i + 1].getBoundingClientRect().top; + if (nextTop - bottom > 2 && + bottom > cr.top && nextTop < cr.bottom) { + return true; + } + } + return false; + ", container); + Assert.False(hasGap is bool g && g, + "No visible gaps should exist between rendered items"); + } + + [Fact] + public void AnchorMode_None_AsyncProvider_PrependKeepsViewportStable() + { + MountAnchorModeComponent("0", variableHeight: true, useItemsProvider: true); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // Enable 500ms provider delay to simulate network latency. + Browser.Exists(By.Id("toggle-delay")).Click(); + Browser.Contains("Provider delay: 500ms", () => Browser.Exists(By.Id("status")).Text); + + ScrollMidListAndWaitForRender(container, js); + // With provider delay, wait for the visible items to fully settle + // (provider round-trips complete and _itemsBefore stabilizes). + WaitForRenderToSettle(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + // Wait for all provider calls to complete (RefreshDataAsync finishes). + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + // Wait for the final render + restore to settle. + WaitForRenderToSettle(container, js); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "Async provider: viewport should stay stable after prepend despite placeholder transition", + driftTolerance: 5); + } + + [Fact] + public void AnchorMode_None_AsyncProvider_ScrollDoesNotFlash() + { + MountAnchorModeComponent("0", variableHeight: true, useItemsProvider: true); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // Enable 500ms provider delay to simulate network latency. + Browser.Exists(By.Id("toggle-delay")).Click(); + Browser.Contains("Provider delay: 500ms", () => Browser.Exists(By.Id("status")).Text); + + // Scroll through items incrementally, checking for backward index jumps (flashing). + var result = js.ExecuteAsyncScript(@" + var done = arguments[arguments.length - 1]; + var container = arguments[0]; + (async () => { + const SCROLL_INCREMENT = 200; + const MAX_ITERATIONS = 50; + const SETTLE_MS = 100; // Short settle — we're testing for flashing, not waiting for full load + + let prevTopIndex = -1; + let flashCount = 0; + let maxIndexSeen = -1; + + for (let i = 0; i < MAX_ITERATIONS; i++) { + container.scrollTop += SCROLL_INCREMENT; + await new Promise(r => setTimeout(r, SETTLE_MS)); + + var items = container.querySelectorAll('.item[data-index]'); + var containerRect = container.getBoundingClientRect(); + let topIndex = -1; + + for (var j = 0; j < items.length; j++) { + var rect = items[j].getBoundingClientRect(); + if (rect.bottom > containerRect.top + 2 && rect.top < containerRect.bottom - 2) { + topIndex = parseInt(items[j].getAttribute('data-index'), 10); + break; + } + } + + if (topIndex >= 0) { + if (topIndex > maxIndexSeen) maxIndexSeen = topIndex; + if (prevTopIndex >= 0 && topIndex < prevTopIndex - 3) { + flashCount++; + } + prevTopIndex = topIndex; + } + + // Stop if we've reached near the end. + if (container.scrollHeight - container.scrollTop - container.clientHeight < 2) break; + } + + done({ flashCount: flashCount, maxIndexSeen: maxIndexSeen }); + })(); + ", container) as Dictionary; + + var flashCount = Convert.ToInt32(result["flashCount"], CultureInfo.InvariantCulture); + var maxIndexSeen = Convert.ToInt32(result["maxIndexSeen"], CultureInfo.InvariantCulture); + + Assert.True(flashCount == 0, + $"Async provider: scrolling should not flash/jump backward. " + + $"Detected {flashCount} backward jumps, max index seen: {maxIndexSeen}"); + Assert.True(maxIndexSeen >= 50, + $"Should have scrolled through some items but only reached index {maxIndexSeen}"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_Beginning_PrependAtTop_NewItemsVisible(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("1", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + WaitForRenderToSettle(container, js); + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.True(scrollTopAfter < 50, + $"Beginning mode: should stay near top after prepend, but scrollTop was {scrollTopAfter}"); + + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-10']")).Count > 0); + } + + // Append at bottom in Beginning mode: the viewport should not auto-scroll. + // Beginning only pins the top edge. Pixel-perfect stability requires height + // cache (Option B); for now we verify it doesn't jump to the new bottom. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_AppendAtBottom_ViewportStable(bool useItemsProvider) + { + MountAnchorModeComponent("1", useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + + // Beginning mode: no convergence to chase the new bottom. + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + var gap = sh - st - ch; + Assert.True(gap > 2000, + $"Beginning mode: should not follow to bottom after append. " + + $"scrollTop: {st}, scrollHeight: {sh}, gap: {gap}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_SmallAppendAtBottom_ViewportStable(bool useItemsProvider) + { + MountAnchorModeComponent("1", useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "Beginning mode at bottom: same item should stay at same position after small append", + driftTolerance: 2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_Beginning_MidList_ViewportStable(bool useItemsProvider) + { + MountAnchorModeComponent("1", useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "Beginning mode mid-list: viewport should stay stable after prepend"); + } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_End_PrependAtTop_ViewportStaysStable(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("2", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // End mode at the top: viewport should stay stable — the same items + // stay visible. scrollTop may increase to compensate for prepended items. + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "End mode at top: viewport should stay stable after prepend", + driftTolerance: variableHeight ? 5 : 2); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_End_AppendAtBottom_ViewportFollows(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("2", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + Browser.True(() => + { + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return sh - st - ch < 2; + }, "End mode: viewport should follow new content to the bottom after append"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_MidList_ViewportStable(bool useItemsProvider) + { + MountAnchorModeComponent("2", useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + "End mode mid-list: viewport should stay stable after prepend"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_End_AppendAtMidList_DoesNotAutoScroll(bool useItemsProvider) + { + MountAnchorModeComponent("2", useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + + var (indexBefore, relTopBefore, scrollTopBefore) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + WaitForRenderToSettle(container, js); + var (_, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item", indexBefore); + Assert.True(Math.Abs(scrollTopAfter - scrollTopBefore) < 2, + $"End mode mid-list: scrollTop should not change on append. Before: {scrollTopBefore}, After: {scrollTopAfter}"); + Assert.True(Math.Abs(relTopAfter - relTopBefore) < 2, + $"End mode mid-list: visible item should not shift on append. " + + $"relTop before: {relTopBefore}, after: {relTopAfter}"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_End_LargeAppendAtBottom_StillFollows(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("2", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + + Browser.True(() => + { + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return sh - st - ch < 2; + }, TimeSpan.FromSeconds(30), "End mode: large append should still follow to bottom"); + } + + [Fact] + public void AnchorMode_End_SmallDataset_AppendFollowsToBottom() + { + MountAnchorModeComponent("2"); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // Reduce to 5 items — all fit in viewport, spacerAfter height should be 0. + Browser.Exists(By.Id("set-small-count")).Click(); + Browser.Contains("Set to 5 items", () => Browser.Exists(By.Id("status")).Text); + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll to bottom (may already be there if content fits). + ScrollToBottomAndWait(container, js); + + // Append 10 items — now there's more content than viewport. + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // End mode should auto-follow to bottom even though spacerAfter was 0 before append. + Browser.True(() => + { + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return st > 0 && sh - st - ch < 2; + }, TimeSpan.FromSeconds(10), "End mode: small dataset append should follow to bottom"); + } + + [Fact] + public void AnchorMode_End_GrowingDataset_AppendFollowsToBottom() + { + MountAnchorModeComponent("2"); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // Reduce to 5 items — all fit in viewport, no scrollbar needed. + Browser.Exists(By.Id("set-small-count")).Click(); + Browser.Contains("Set to 5 items", () => Browser.Exists(By.Id("status")).Text); + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Add items one at a time. End mode should keep following to bottom + // even before a scrollbar appears (spacerAfter is 0). + for (var i = 0; i < 20; i++) + { + Browser.Exists(By.Id("append-one-item")).Click(); + Browser.Contains("Appended 1 item", () => Browser.Exists(By.Id("status")).Text); + } + + // After adding 20 items (total 25), a scrollbar should exist + // and End mode should have followed to the bottom. + Browser.True(() => + { + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return st > 0 && sh - st - ch < 2; + }, TimeSpan.FromSeconds(10), + "End mode: growing dataset (one at a time) should follow to bottom"); + } + + [Theory(Skip = "https://github.com/dotnet/aspnetcore/issues/66509")] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_DeleteAboveViewport_ViewportStaysStable(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + WaitForRenderToSettle(container, js); + + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("delete-above")).Click(); + Browser.Contains("Deleted 10 items from above", () => Browser.Exists(By.Id("status")).Text); + WaitForRenderToSettle(container, js); + + AssertViewportStaysStable( + js, + By.Id("scroll-container"), + ".item", + indexBefore, + relTopBefore, + $"AnchorMode {anchorMode} (provider={useItemsProvider}): viewport should stay stable after deleting items above", + driftTolerance: 5); + } + + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_DeleteBelowViewport_ViewportStaysStable(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollMidListAndWaitForRender(container, js); + WaitForRenderToSettle(container, js); + + var (indexBefore, relTopBefore, scrollTopBefore) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("delete-below")).Click(); + Browser.Contains("Deleted 10 items from below", () => Browser.Exists(By.Id("status")).Text); + WaitForRenderToSettle(container, js); + + var (indexAfter, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item"); + Assert.Equal(indexBefore, indexAfter); + Assert.True(Math.Abs(scrollTopAfter - scrollTopBefore) < 5, + $"AnchorMode {anchorMode} (provider={useItemsProvider}): scrollTop should not change after deleting items below. " + + $"Before: {scrollTopBefore}, After: {scrollTopAfter}"); + } + + [Theory] + [InlineData("0", false)] + [InlineData("1", false)] + [InlineData("2", false)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData("2", true)] + public void AnchorMode_DeleteAtViewportTop_FirstSurvivingItemJumpsToTheTop(string anchorMode, bool useItemsProvider) + { + MountAnchorModeComponent(anchorMode, useItemsProvider: useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + // Scroll down a small amount so the anchor item (around index 4-5) is + // within the range of "Delete 10 from top" (items 0-9). + ScrollUntil(js, container, () => ScrollContainer(js, container, 200), + st => st > 100, "scrollTop > 100 after ScrollContainer(200)"); + WaitForRenderToSettle(container, js); + + // Delete 10 from the top — this removes items 0-9, including the anchor. + Browser.Exists(By.Id("delete-above")).Click(); + Browser.Contains("Deleted 10 items from above", () => Browser.Exists(By.Id("status")).Text); + WaitForRenderToSettle(container, js); + + // Items should still be rendered. + Browser.True(() => GetElementCount(container, ".item") > 0, + "Items should still be rendered after deletion at viewport"); + + // The first visible item should be the first surviving item (index 10), + // since items 0-9 were deleted and the viewport shifts up naturally. + var (firstVisibleIndex, _, _) = GetItemPositionInContainer(js, container, ".item"); + var firstVisibleIdx = int.Parse(firstVisibleIndex, CultureInfo.InvariantCulture); + Assert.True(firstVisibleIdx >= 10, + $"After deleting items 0-9, the first visible item should be >= 10, but was {firstVisibleIdx}"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_Beginning_LargePrependAtTop_StillShowsNewItems(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("1", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-many-items")).Click(); + Browser.Contains("Prepended 100 items", () => Browser.Exists(By.Id("status")).Text); + + WaitForRenderToSettle(container, js); + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.True(scrollTopAfter < 50, + $"Beginning mode: large prepend should still pin to top, but scrollTop was {scrollTopAfter}"); + + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-100']")).Count > 0); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_End_AppendAfterLeavingBottom_DoesNotReengage(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("2", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + Browser.True(() => + { + var st = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + return sh - st - ch < 2; + }, "End mode: first append should follow to bottom"); + + var currentScrollTop = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var targetScrollTop = (int)(currentScrollTop - 500); + ScrollUntil(js, container, () => ScrollContainer(js, container, targetScrollTop), + st => st < currentScrollTop - 100, + $"scrollTop < {currentScrollTop - 100} after scrolling away from bottom"); + + var scrollTopBeforeSecondAppend = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var scrollHeightBeforeSecondAppend = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var clientHeightBeforeSecondAppend = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + WaitForRenderToSettle(container, js); + var scrollTopAfterSecondAppend = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var scrollHeightAfterSecondAppend = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + Assert.True(Math.Abs(scrollTopAfterSecondAppend - scrollTopBeforeSecondAppend) < 2, + $"End mode: should not re-engage after leaving bottom. " + + $"scrollTop before: {scrollTopBeforeSecondAppend}, after: {scrollTopAfterSecondAppend}, " + + $"scrollHeight before: {scrollHeightBeforeSecondAppend}, after: {scrollHeightAfterSecondAppend}, " + + $"clientHeight: {clientHeightBeforeSecondAppend}"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_Beginning_PrependAfterLeavingTop_DoesNotReengage(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("1", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + Assert.Equal(0, (long)js.ExecuteScript("return arguments[0].scrollTop", container)); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-10']")).Count > 0); + WaitForRenderToSettle(container, js); + + ScrollUntil(js, container, () => ScrollContainer(js, container, 500), + st => st > 200, "scrollTop > 200 after ScrollContainer(500)"); + + var scrollTopBefore = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + WaitForRenderToSettle(container, js); + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + Assert.True(scrollTopAfter > 200, + $"Beginning mode: should not pull user back to top after leaving. " + + $"scrollTop before: {scrollTopBefore}, after: {scrollTopAfter} (expected >200, not near 0)"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_Beginning_LargeAppendAtBottom_DoesNotFollowToBottom(bool variableHeight, bool useItemsProvider) + { + MountAnchorModeComponent("1", variableHeight, useItemsProvider); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + + ScrollToBottomAndWait(container, js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + // Beginning mode: no convergence to chase the new bottom. + var st2 = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + var sh2 = (long)js.ExecuteScript("return arguments[0].scrollHeight", container); + var ch2 = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + var gap = sh2 - st2 - ch2; + Assert.True(gap > 2000, + $"Beginning mode: should not converge to bottom after large append. " + + $"scrollTop: {st2}, scrollHeight: {sh2}, gap: {gap}"); + } + + private void MountWindowScrollAnchorModeComponent(string anchorMode, bool variableHeight = false, bool useItemsProvider = false) + { + Browser.MountTestComponent(); + var root = Browser.Exists(By.Id("virtualize-root")); + Browser.True(() => GetElementCount(root, ".item") > 0); + + if (useItemsProvider) + { + Browser.Exists(By.Id("toggle-provider")).Click(); + Browser.True(() => GetElementCount(root, ".item") > 0); + } + + if (variableHeight) + { + Browser.Exists(By.Id("toggle-height")).Click(); + Browser.True(() => GetElementCount(root, ".item") > 0); + } + + var select = Browser.Exists(By.Id("anchor-mode-select")); + var selectElement = new SelectElement(select); + selectElement.SelectByValue(anchorMode); + + Browser.True(() => Browser.Exists(By.Id("current-mode")).Text == anchorMode); + Browser.True(() => GetElementCount(root, ".item") > 0); + } + + private void WindowScrollToBottomAndWait(IJavaScriptExecutor js) + { + Browser.True(() => + { + js.ExecuteScript("window.scrollTo(0, document.documentElement.scrollHeight)"); + var scrollY = (long)js.ExecuteScript("return Math.round(window.scrollY)"); + var scrollHeight = (long)js.ExecuteScript("return document.documentElement.scrollHeight"); + var innerHeight = (long)js.ExecuteScript("return window.innerHeight"); + return scrollHeight - scrollY - innerHeight < 2; + }, TimeSpan.FromSeconds(10)); + + Browser.True(() => + { + var found = js.ExecuteScript(@" + var items = document.querySelectorAll('.item[data-index]'); + for (var i = 0; i < items.length; i++) { + var ir = items[i].getBoundingClientRect(); + if (ir.bottom > 1 && ir.top < window.innerHeight - 1) return true; + } + return false; + "); + return found is bool visible && visible; + }); + } + + private void WindowScrollMidListAndWaitForRender(IJavaScriptExecutor js) + { + js.ExecuteScript("window.scrollTo(0, 5000)"); + Browser.True(() => + { + var scrollY = (long)js.ExecuteScript("return Math.round(window.scrollY)"); + return scrollY > 4000; + }, TimeSpan.FromSeconds(5)); + + // Wait for Virtualize to render items visible in the viewport. + Browser.True(() => + { + var found = js.ExecuteScript(@" + var items = document.querySelectorAll('.item[data-index]'); + for (var i = 0; i < items.length; i++) { + var ir = items[i].getBoundingClientRect(); + if (ir.bottom > 1 && ir.top < window.innerHeight - 1) return true; + } + return false; + "); + return found is bool visible && visible; + }, TimeSpan.FromSeconds(10)); + } + + private void AssertWindowScrollViewportStaysStable( + IJavaScriptExecutor js, + IWebElement root, + string indexBefore, + double topBefore, + string message, + double driftTolerance = 5) + { + Browser.True(() => + { + try + { + var (_, currentTop) = GetItemPositionInViewport(js, root, ".item[data-index]", indexBefore); + return Math.Abs(currentTop - topBefore) <= driftTolerance; + } + catch + { + return false; + } + }, TimeSpan.FromSeconds(10)); + + var (_, topAfter) = GetItemPositionInViewport(js, root, ".item[data-index]", indexBefore); + Assert.True(Math.Abs(topAfter - topBefore) <= driftTolerance, + $"{message} (item {indexBefore} moved from {topBefore} to {topAfter})"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_WindowScroll_None_PrependAtTop_ViewportStaysStable(bool variableHeight, bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("0", variableHeight, useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + var (indexBefore, topBefore) = GetItemPositionInViewport(js, root, ".item[data-index]"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Wait for anchor restore to compensate scrollY. + Browser.True(() => (long)js.ExecuteScript("return Math.round(window.scrollY)") > 50, + TimeSpan.FromSeconds(10)); + + AssertWindowScrollViewportStaysStable(js, root, indexBefore, topBefore, + "Window None mode at top: same item should stay at same position after prepend"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_WindowScroll_None_LargeAppendAtBottom_DoesNotFollowToBottom(bool variableHeight, bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("0", variableHeight, useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + + WindowScrollToBottomAndWait(js); + + Browser.Exists(By.Id("append-many-items")).Click(); + Browser.Contains("Appended 100 items", () => Browser.Exists(By.Id("status")).Text); + + var scrollY = (long)js.ExecuteScript("return Math.round(window.scrollY)"); + var scrollHeight = (long)js.ExecuteScript("return document.documentElement.scrollHeight"); + var innerHeight = (long)js.ExecuteScript("return window.innerHeight"); + var gap = scrollHeight - scrollY - innerHeight; + Assert.True(gap > 2000, + $"Window None mode: should not converge to bottom after large append. scrollY: {scrollY}, gap: {gap}"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_WindowScroll_None_MidList_ViewportStable(bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("0", useItemsProvider: useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + WindowScrollMidListAndWaitForRender(js); + + var (indexBefore, topBefore) = GetItemPositionInViewport(js, root, ".item[data-index]"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertWindowScrollViewportStaysStable(js, root, indexBefore, topBefore, + "Window None mode mid-list: viewport should stay stable after prepend"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_WindowScroll_Beginning_PrependAtTop_NewItemsVisible(bool variableHeight, bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("1", variableHeight, useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + Browser.True(() => + { + var (idx, _) = GetItemPositionInViewport(js, root, ".item[data-index]"); + return int.TryParse(idx, out var parsed) && parsed < 0; + }, TimeSpan.FromSeconds(10), + "Window Beginning mode: prepended items should be visible at top"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_WindowScroll_Beginning_AppendAtBottom_ViewportStable(bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("1", useItemsProvider: useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + WindowScrollToBottomAndWait(js); + + var (indexBefore, topBefore) = GetItemPositionInViewport(js, root, ".item[data-index]"); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertWindowScrollViewportStaysStable(js, root, indexBefore, topBefore, + "Window Beginning mode at bottom: viewport should stay stable after append"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_WindowScroll_Beginning_MidList_ViewportStable(bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("1", useItemsProvider: useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + WindowScrollMidListAndWaitForRender(js); + + var (indexBefore, topBefore) = GetItemPositionInViewport(js, root, ".item[data-index]"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertWindowScrollViewportStaysStable(js, root, indexBefore, topBefore, + "Window Beginning mode mid-list: viewport should stay stable after prepend"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_WindowScroll_End_PrependAtTop_ViewportStaysStable(bool variableHeight, bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("2", variableHeight, useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + var (indexBefore, topBefore) = GetItemPositionInViewport(js, root, ".item[data-index]"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + Browser.True(() => (long)js.ExecuteScript("return Math.round(window.scrollY)") > 50, + TimeSpan.FromSeconds(10)); + + AssertWindowScrollViewportStaysStable(js, root, indexBefore, topBefore, + "Window End mode at top: same item should stay at same position after prepend"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void AnchorMode_WindowScroll_End_AppendAtBottom_ViewportFollows(bool variableHeight, bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("2", variableHeight, useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + + WindowScrollToBottomAndWait(js); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + Browser.True(() => + { + var scrollY = (long)js.ExecuteScript("return Math.round(window.scrollY)"); + var scrollHeight = (long)js.ExecuteScript("return document.documentElement.scrollHeight"); + var innerHeight = (long)js.ExecuteScript("return window.innerHeight"); + return scrollHeight - scrollY - innerHeight < 2; + }, TimeSpan.FromSeconds(10), + "Window End mode: viewport should follow new content to the bottom after append"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnchorMode_WindowScroll_End_MidList_ViewportStable(bool useItemsProvider) + { + MountWindowScrollAnchorModeComponent("2", useItemsProvider: useItemsProvider); + + var js = (IJavaScriptExecutor)Browser; + var root = Browser.Exists(By.Id("virtualize-root")); + + WindowScrollMidListAndWaitForRender(js); + + var (indexBefore, topBefore) = GetItemPositionInViewport(js, root, ".item[data-index]"); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + AssertWindowScrollViewportStaysStable(js, root, indexBefore, topBefore, + "Window End mode mid-list: viewport should stay stable after prepend"); + } + + [Theory] + [InlineData("0")] + [InlineData("1")] + [InlineData("2")] + public void AnchorMode_WindowScroll_EndKeyJumpsToBottom(string anchorMode) + { + MountWindowScrollAnchorModeComponent(anchorMode); + + var js = (IJavaScriptExecutor)Browser; + + // Press End via keyboard — window scroll uses document keydown + var body = Browser.Exists(By.TagName("body")); + body.SendKeys(Keys.End); + + Browser.True(() => + { + var scrollY = (long)js.ExecuteScript("return Math.round(window.scrollY)"); + var scrollHeight = (long)js.ExecuteScript("return document.documentElement.scrollHeight"); + var innerHeight = (long)js.ExecuteScript("return window.innerHeight"); + return scrollHeight - scrollY - innerHeight < 2; + }, TimeSpan.FromSeconds(10), + $"Window AnchorMode {anchorMode}: End key should jump to the bottom"); + } + + [Theory] + [InlineData("0")] + [InlineData("1")] + [InlineData("2")] + public void AnchorMode_WindowScroll_HomeKeyJumpsToTop(string anchorMode) + { + MountWindowScrollAnchorModeComponent(anchorMode); + + var js = (IJavaScriptExecutor)Browser; + + // Scroll to mid-list first. + WindowScrollMidListAndWaitForRender(js); + + var body = Browser.Exists(By.TagName("body")); + body.SendKeys(Keys.Home); + + Browser.True(() => + { + var scrollY = (long)js.ExecuteScript("return Math.round(window.scrollY)"); + return scrollY < 2; + }, TimeSpan.FromSeconds(10), + $"Window AnchorMode {anchorMode}: Home key should jump to the top"); + } + private static (string index, double relTop, long scrollTop) GetItemPositionInContainer( + IJavaScriptExecutor js, IWebElement container, string itemSelector, string dataIndex = null) + { + var result = js.ExecuteScript(@" + var container = arguments[0]; + var selector = arguments[1]; + var targetIndex = arguments[2]; + var containerRect = container.getBoundingClientRect(); + var items = container.querySelectorAll(selector); + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var itemRect = item.getBoundingClientRect(); + if (targetIndex != null) { + if (item.getAttribute('data-index') === targetIndex) { + return { index: targetIndex, relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; + } + } else if (itemRect.bottom > containerRect.top + 1 && itemRect.top < containerRect.bottom - 1) { + return { index: item.getAttribute('data-index'), relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; + } + } + return null; + ", container, itemSelector, dataIndex) as Dictionary; + + Assert.NotNull(result); + return ( + result["index"].ToString(), + Convert.ToDouble(result["relTop"], CultureInfo.InvariantCulture), + Convert.ToInt64(result["scrollTop"], CultureInfo.InvariantCulture)); + } + + private void AssertViewportStaysStable( + IJavaScriptExecutor js, + By containerSelector, + string itemSelector, + string indexBefore, + double relTopBefore, + string message, + double driftTolerance = 0, + bool compareWholePixels = false) + { + (string index, double relTop, long scrollTop) lastPos = default; + + var container = Browser.Exists(containerSelector); + lastPos = GetItemPositionInContainer(js, container, itemSelector); + + var indexMatch = lastPos.index == indexBefore; + var drift = compareWholePixels + ? Math.Abs((int)Math.Round(lastPos.relTop) - (int)Math.Round(relTopBefore)) + : Math.Abs(lastPos.relTop - relTopBefore); + + Assert.True(indexMatch && drift <= driftTolerance, + $"{message} (index before: {indexBefore}, after: {lastPos.index}, relTop before: {relTopBefore}, after: {lastPos.relTop}, scrollTop: {lastPos.scrollTop}, tolerance: {driftTolerance})"); + } + + /// + /// Waits for the Virtualize render cycle to settle by checking that the rendered + /// item count, scrollTop, and first visible item index stabilize. + /// Use after actions that trigger async rendering (prepend/append with ItemsProvider on Server) + /// to ensure anchor restore has completed before making single-shot assertions. + /// + private void WaitForRenderToSettle(IWebElement container, IJavaScriptExecutor js) + { + long lastScrollTop = -1; + int lastItemCount = -1; + string lastFirstIndex = ""; + int stableCount = 0; + + Browser.True(() => + { + var result = js.ExecuteScript(@" + var c = arguments[0]; + var items = c.querySelectorAll('.item[data-index]'); + var cr = c.getBoundingClientRect(); + var firstIdx = ''; + for (var i = 0; i < items.length; i++) { + var r = items[i].getBoundingClientRect(); + if (r.bottom > cr.top + 2 && r.top < cr.bottom - 2) { + firstIdx = items[i].getAttribute('data-index'); + break; + } + } + return { scrollTop: Math.round(c.scrollTop), itemCount: items.length, firstIndex: firstIdx }; + ", container) as Dictionary; + + var scrollTop = Convert.ToInt64(result["scrollTop"], CultureInfo.InvariantCulture); + var itemCount = Convert.ToInt32(result["itemCount"], CultureInfo.InvariantCulture); + var firstIndex = result["firstIndex"]?.ToString() ?? ""; + + if (scrollTop == lastScrollTop && itemCount == lastItemCount && firstIndex == lastFirstIndex) + { + stableCount++; + } + else + { + stableCount = 0; + } + + lastScrollTop = scrollTop; + lastItemCount = itemCount; + lastFirstIndex = firstIndex; + + // Require 3 consecutive stable reads to account for async provider delays. + return stableCount >= 3; + }, TimeSpan.FromSeconds(15), "Render cycle did not settle in time"); + } + + private static (string index, double top) GetItemPositionInViewport( + IJavaScriptExecutor js, IWebElement root, string itemSelector, string dataIndex = null) + { + var result = js.ExecuteScript(@" + var root = arguments[0]; + var selector = arguments[1]; + var targetIndex = arguments[2]; + var items = root.querySelectorAll(selector); + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var itemRect = item.getBoundingClientRect(); + if (targetIndex != null) { + if (item.getAttribute('data-index') === targetIndex) { + return { index: targetIndex, top: itemRect.top }; + } + } else if (itemRect.bottom > 1 && itemRect.top < window.innerHeight - 1) { + return { index: item.getAttribute('data-index'), top: itemRect.top }; + } + } + return null; + ", root, itemSelector, dataIndex) as Dictionary; + + Assert.NotNull(result); + return ( + result["index"].ToString(), + Convert.ToDouble(result["top"], CultureInfo.InvariantCulture)); } /// diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 093215e0b2c6..94d08007f215 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -134,6 +134,8 @@ + + diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor new file mode 100644 index 000000000000..e46da1494f3e --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -0,0 +1,318 @@ +@using Microsoft.AspNetCore.Components.Web.Virtualization + +

Virtualization Anchor Mode

+ +
+ +
+ +
+ @if (useItemsProvider) + { + +
+
Item @item.Index
+
+
+ } + else + { + +
+
Item @item.Index
+
+
+ } +
+ +
+ + + + + + + + + + + + +
+ +

@statusMessage

+

@((int)anchorMode)

+ +@code { + private List items = new(); + private string statusMessage = "Ready"; + private VirtualizeAnchorMode anchorMode = VirtualizeAnchorMode.Beginning; + private bool useVariableHeight = false; + private bool useItemsProvider = false; + private bool useProviderDelay = false; + private Virtualize virtualizeRef; + private static readonly IEqualityComparer _itemComparer = + EqualityComparer.Create((a, b) => a.Index == b.Index, item => item.Index); + + // Pending mutations applied inside the provider (simulates DB-backed data). + private List _pendingPrepend; + private List _pendingAppend; + private int _pendingDeleteFromTop; + private int _pendingDeleteFromBottom; + + protected override void OnInitialized() + { + items = Enumerable.Range(0, 500) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + } + + private int GetHeight(int index) + { + return useVariableHeight ? 10 + (Math.Abs(index * 7 + 13) % 191) : 50; + } + + private void ToggleVariableHeight() + { + useVariableHeight = !useVariableHeight; + foreach (var item in items) + { + item.Height = GetHeight(item.Index); + } + statusMessage = useVariableHeight ? "Switched to variable heights" : "Switched to fixed heights"; + } + + private void OnAnchorModeChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var value)) + { + anchorMode = (VirtualizeAnchorMode)value; + statusMessage = $"AnchorMode changed to {anchorMode}"; + } + } + + private int nextPrependIndex = -1; + private int nextAppendIndex = 500; + + private async Task PrependItems() + { + var newItems = Enumerable.Range(0, 10) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = GetHeight(nextPrependIndex - i) }) + .Reverse() + .ToList(); + nextPrependIndex -= 10; + if (useItemsProvider && virtualizeRef != null) + { + _pendingPrepend = newItems; + await virtualizeRef.RefreshDataAsync(); + } + else + { + items.InsertRange(0, newItems); + } + statusMessage = $"Prepended 10 items (indices {newItems.First().Index}..{newItems.Last().Index})"; + } + + private async Task PrependManyItems() + { + var newItems = Enumerable.Range(0, 100) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = GetHeight(nextPrependIndex - i) }) + .Reverse() + .ToList(); + nextPrependIndex -= 100; + if (useItemsProvider && virtualizeRef != null) + { + _pendingPrepend = newItems; + await virtualizeRef.RefreshDataAsync(); + } + else + { + items.InsertRange(0, newItems); + } + statusMessage = $"Prepended 100 items (indices {newItems.First().Index}..{newItems.Last().Index})"; + } + + private async Task AppendItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 10) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + nextAppendIndex += 10; + if (useItemsProvider && virtualizeRef != null) + { + _pendingAppend = newItems; + await virtualizeRef.RefreshDataAsync(); + } + else + { + items.AddRange(newItems); + } + statusMessage = $"Appended 10 items (indices {startIndex}..{startIndex + 9})"; + } + + private async Task AppendOneItem() + { + var index = nextAppendIndex; + var newItem = new DynamicItem { Index = index, Height = GetHeight(index) }; + nextAppendIndex += 1; + if (useItemsProvider && virtualizeRef != null) + { + _pendingAppend = new List { newItem }; + await virtualizeRef.RefreshDataAsync(); + } + else + { + items.Add(newItem); + } + statusMessage = $"Appended 1 item (index {index})"; + } + + private async Task AppendManyItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 100) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + nextAppendIndex += 100; + if (useItemsProvider && virtualizeRef != null) + { + _pendingAppend = newItems; + await virtualizeRef.RefreshDataAsync(); + } + else + { + items.AddRange(newItems); + } + statusMessage = $"Appended 100 items (indices {startIndex}..{startIndex + 99})"; + } + + private async Task SetSmallCount() + { + items = Enumerable.Range(0, 5) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + nextPrependIndex = -1; + nextAppendIndex = 5; + statusMessage = "Set to 5 items"; + if (useItemsProvider && virtualizeRef != null) { await virtualizeRef.RefreshDataAsync(); } + } + + private void ExpandVisibleItem() + { + var target = items.FirstOrDefault(i => i.Index == 3); + if (target != null) + { + target.Height = 400; + statusMessage = $"Expanded item {target.Index}"; + } + else + { + statusMessage = "No item to expand"; + } + } + + private async Task DeleteAbove() + { + var count = Math.Min(10, items.Count); + if (useItemsProvider && virtualizeRef != null) + { + _pendingDeleteFromTop = count; + await virtualizeRef.RefreshDataAsync(); + } + else + { + items.RemoveRange(0, count); + } + statusMessage = $"Deleted {count} items from above"; + } + + private async Task DeleteBelow() + { + var count = Math.Min(10, items.Count); + if (useItemsProvider && virtualizeRef != null) + { + _pendingDeleteFromBottom = count; + await virtualizeRef.RefreshDataAsync(); + } + else + { + var startIdx = items.Count - count; + items.RemoveRange(startIdx, count); + } + statusMessage = $"Deleted {count} items from below"; + } + + private void ToggleProvider() + { + useItemsProvider = !useItemsProvider; + statusMessage = useItemsProvider ? "Switched to ItemsProvider" : "Switched to Items"; + } + + private async ValueTask> GetItemsAsync(ItemsProviderRequest request) + { + await Task.Yield(); + if (useProviderDelay) + { + await Task.Delay(500); + } + + // Apply pending mutations (simulates fetching updated data from a DB). + if (_pendingPrepend != null) + { + items.InsertRange(0, _pendingPrepend); + _pendingPrepend = null; + } + if (_pendingAppend != null) + { + items.AddRange(_pendingAppend); + _pendingAppend = null; + } + if (_pendingDeleteFromTop > 0) + { + var count = Math.Min(_pendingDeleteFromTop, items.Count); + items.RemoveRange(0, count); + _pendingDeleteFromTop = 0; + } + if (_pendingDeleteFromBottom > 0) + { + var count = Math.Min(_pendingDeleteFromBottom, items.Count); + items.RemoveRange(items.Count - count, count); + _pendingDeleteFromBottom = 0; + } + + var result = items.Skip(request.StartIndex).Take(request.Count) + .Select(i => new DynamicItem { Index = i.Index, Height = i.Height }); + return new ItemsProviderResult(result, items.Count); + } + + private void ToggleDelay() + { + useProviderDelay = !useProviderDelay; + statusMessage = useProviderDelay ? "Provider delay: 500ms" : "Provider delay: None"; + } + + private class DynamicItem + { + public int Index { get; set; } + public int Height { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor new file mode 100644 index 000000000000..1e0f9e6893f0 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor @@ -0,0 +1,239 @@ +@using Microsoft.AspNetCore.Components.Web.Virtualization + + + +
+
+ + + + + + + + +
+

@statusMessage

+

@((int)anchorMode)

+
+ +
+ @* No scroll container — uses window/document as the scroll root *@ + @if (useItemsProvider) + { + +
+
Item @item.Index
+
+
+ } + else + { + +
+
Item @item.Index
+
+
+ } +
+ +@code { + private List items = new(); + private string statusMessage = "Ready"; + private VirtualizeAnchorMode anchorMode = VirtualizeAnchorMode.Beginning; + private bool useVariableHeight = false; + private bool useItemsProvider = false; + private Virtualize virtualizeRef; + private static readonly IEqualityComparer _itemComparer = + EqualityComparer.Create((a, b) => a.Index == b.Index, item => item.Index); + + protected override void OnInitialized() + { + items = Enumerable.Range(0, 500) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + } + + private int GetHeight(int index) + { + return useVariableHeight ? 10 + (Math.Abs(index * 7 + 13) % 191) : 50; + } + + private void ToggleVariableHeight() + { + useVariableHeight = !useVariableHeight; + foreach (var item in items) + { + item.Height = GetHeight(item.Index); + } + statusMessage = useVariableHeight ? "Switched to variable heights" : "Switched to fixed heights"; + } + + private void OnAnchorModeChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var value)) + { + anchorMode = (VirtualizeAnchorMode)value; + statusMessage = $"AnchorMode changed to {anchorMode}"; + } + } + + private int nextPrependIndex = -1; + private int nextAppendIndex = 500; + + // Pending mutations applied inside the provider callback. + private List _pendingPrepend; + private List _pendingAppend; + + private async Task PrependItems() + { + var newItems = Enumerable.Range(0, 10) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = GetHeight(nextPrependIndex - i) }) + .Reverse() + .ToList(); + nextPrependIndex -= 10; + + if (useItemsProvider) + { + _pendingPrepend = newItems; + statusMessage = $"Prepended 10 items (indices {newItems.First().Index}..{newItems.Last().Index})"; + if (virtualizeRef != null) { await virtualizeRef.RefreshDataAsync(); } + } + else + { + items.InsertRange(0, newItems); + statusMessage = $"Prepended 10 items (indices {newItems.First().Index}..{newItems.Last().Index})"; + } + } + + private async Task PrependManyItems() + { + var newItems = Enumerable.Range(0, 100) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = GetHeight(nextPrependIndex - i) }) + .Reverse() + .ToList(); + nextPrependIndex -= 100; + + if (useItemsProvider) + { + _pendingPrepend = newItems; + statusMessage = $"Prepended 100 items (indices {newItems.First().Index}..{newItems.Last().Index})"; + if (virtualizeRef != null) { await virtualizeRef.RefreshDataAsync(); } + } + else + { + items.InsertRange(0, newItems); + statusMessage = $"Prepended 100 items (indices {newItems.First().Index}..{newItems.Last().Index})"; + } + } + + private async Task AppendItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 10) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + nextAppendIndex += 10; + + if (useItemsProvider) + { + _pendingAppend = newItems; + statusMessage = $"Appended 10 items (indices {startIndex}..{startIndex + 9})"; + if (virtualizeRef != null) { await virtualizeRef.RefreshDataAsync(); } + } + else + { + items.AddRange(newItems); + statusMessage = $"Appended 10 items (indices {startIndex}..{startIndex + 9})"; + } + } + + private async Task AppendManyItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 100) + .Select(i => new DynamicItem { Index = i, Height = GetHeight(i) }) + .ToList(); + nextAppendIndex += 100; + + if (useItemsProvider) + { + _pendingAppend = newItems; + statusMessage = $"Appended 100 items (indices {startIndex}..{startIndex + 99})"; + if (virtualizeRef != null) { await virtualizeRef.RefreshDataAsync(); } + } + else + { + items.AddRange(newItems); + statusMessage = $"Appended 100 items (indices {startIndex}..{startIndex + 99})"; + } + } + + private void ExpandVisibleItem() + { + var target = items.FirstOrDefault(i => i.Index == 3); + if (target != null) + { + target.Height = 400; + statusMessage = $"Expanded item {target.Index}"; + } + else + { + statusMessage = "No item to expand"; + } + } + + private void ToggleProvider() + { + useItemsProvider = !useItemsProvider; + statusMessage = useItemsProvider ? "Switched to ItemsProvider" : "Switched to Items"; + } + + private async ValueTask> GetItemsAsync(ItemsProviderRequest request) + { + await Task.Yield(); + + // Apply pending mutations inside the provider callback. + if (_pendingPrepend != null) + { + items.InsertRange(0, _pendingPrepend); + _pendingPrepend = null; + } + if (_pendingAppend != null) + { + items.AddRange(_pendingAppend); + _pendingAppend = null; + } + + var result = items.Skip(request.StartIndex).Take(request.Count) + .Select(i => new DynamicItem { Index = i.Index, Height = i.Height }); + return new ItemsProviderResult(result, items.Count); + } + + private class DynamicItem + { + public int Index { get; set; } + public int Height { get; set; } + } +}