diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 86a99854cf95..dfee502d5766 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -6,9 +6,13 @@ import { DotNet } from '@microsoft/dotnet-js-interop'; export const Virtualize = { init, dispose, + scrollToBottom, + measureRenderedItems, + refreshObservers, }; const dispatcherObserversByDotNetIdPropname = Symbol(); +const THROTTLE_MS = 50; function findClosestScrollContainer(element: HTMLElement | null): HTMLElement | null { // If we recurse up as far as body or the document root, return null so that the @@ -28,6 +32,49 @@ function findClosestScrollContainer(element: HTMLElement | null): HTMLElement | return findClosestScrollContainer(element.parentElement); } +function getScaleFactor(spacerBefore: HTMLElement, spacerAfter: HTMLElement): number { + const el = spacerBefore.offsetHeight > 0 ? spacerBefore + : spacerAfter.offsetHeight > 0 ? spacerAfter + : null; + if (!el) { + return 1; + } + const scale = el.getBoundingClientRect().height / el.offsetHeight; + return (Number.isFinite(scale) && scale > 0) ? scale : 1; +} + +interface MeasurementResult { + heightSum: number; + heightCount: number; + scaleFactor: number; +} + +function measureRenderedItems(spacerBefore: HTMLElement, spacerAfter: HTMLElement): MeasurementResult { + const scaleFactor = getScaleFactor(spacerBefore, spacerAfter); + + // Collect comment delimiters between spacers (N+1 fence for N items). + const delimiters: Comment[] = []; + for (let node: Node | null = spacerBefore.nextSibling; node && node !== spacerAfter; node = node.nextSibling) { + if (node.nodeType === Node.COMMENT_NODE && node.textContent === 'virtualize:item') { + delimiters.push(node as Comment); + } + } + + if (delimiters.length < 2) { + return { heightSum: 0, heightCount: 0, scaleFactor }; + } + + // Measure total height from first to last delimiter. Using a single Range + // naturally includes any table border-spacing between rows. + const heightCount = delimiters.length - 1; + const range = document.createRange(); + range.setStartAfter(delimiters[0]); + range.setEndBefore(delimiters[delimiters.length - 1]); + const heightSum = range.getBoundingClientRect().height / scaleFactor; + + return { heightSum, heightCount, scaleFactor }; +} + function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, 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. @@ -39,7 +86,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // would update the scroll position to compensate. Then the spacer would remain visible and we'd keep on // trying to resize it. const scrollContainer = findClosestScrollContainer(spacerBefore); - (scrollContainer || document.documentElement).style.overflowAnchor = 'none'; + const scrollElement = scrollContainer || document.documentElement; + scrollElement.style.overflowAnchor = 'none'; const rangeBetweenSpacers = document.createRange(); @@ -56,70 +104,239 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectionObserver.observe(spacerBefore); intersectionObserver.observe(spacerAfter); - const mutationObserverBefore = createSpacerMutationObserver(spacerBefore); - const mutationObserverAfter = createSpacerMutationObserver(spacerAfter); + let convergingElements = false; + let convergenceItems: Set = new Set(); + + // ResizeObserver roles: + // 1. Always observes both spacers so that when a spacer resizes we re-trigger the + // IntersectionObserver — which otherwise won't fire again for an element that is already visible. + // 2. For convergence (sticky-top/bottom) - observes elements for geometry changes, drives the scroll position. + 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. + if (convergingToBottom || convergingToTop) { + scrollElement.scrollTop = convergingToBottom ? scrollElement.scrollHeight : 0; + const spacer = convergingToBottom ? spacerAfter : spacerBefore; + if (spacer.offsetHeight === 0) { + convergingToBottom = convergingToTop = false; + stopConvergenceObserving(); + } + } else if (convergingElements) { + stopConvergenceObserving(); + } + }); + + // Always observe both spacers for the IntersectionObserver re-trigger. + resizeObserver.observe(spacerBefore); + resizeObserver.observe(spacerAfter); + + function refreshObservedElements(): void { + // C# style updates overwrite the entire style attribute, losing display: table-row. + // Re-apply it so spacers participate in table layout alongside bare items. + if (isValidTableElement(spacerAfter.parentElement)) { + spacerBefore.style.display = 'table-row'; + spacerAfter.style.display = 'table-row'; + } + + // Ensure spacers are always observed (idempotent). + resizeObserver.observe(spacerBefore); + resizeObserver.observe(spacerAfter); + + // During convergence, keep the observed element set in sync with the DOM. + if (convergingElements) { + const currentItems: Set = new Set(); + for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { + resizeObserver.observe(el); + currentItems.add(el); + } + // Unobserve items removed during re-render. + for (const el of convergenceItems) { + if (!currentItems.has(el)) { + resizeObserver.unobserve(el); + } + } + convergenceItems = currentItems; + } + + // Don't re-trigger IntersectionObserver here — ResizeObserver handles that + // when spacers actually resize. Doing it on every render causes feedback loops. + } + + function startConvergenceObserving(): void { + if (convergingElements) return; + convergingElements = true; + for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { + resizeObserver.observe(el); + convergenceItems.add(el); + } + } + + function stopConvergenceObserving(): void { + if (!convergingElements) return; + convergingElements = false; + for (const el of convergenceItems) { + resizeObserver.unobserve(el); + } + convergenceItems.clear(); + } + + let convergingToBottom = false; + let convergingToTop = false; + + let pendingJumpToEnd = false; + let pendingJumpToStart = false; + + const keydownTarget: EventTarget = scrollContainer || document; + function handleJumpKeys(e: Event): void { + const ke = e as KeyboardEvent; + if (ke.key === 'End') { + pendingJumpToEnd = true; + pendingJumpToStart = false; + } else if (ke.key === 'Home') { + pendingJumpToStart = true; + pendingJumpToEnd = false; + } + } + keydownTarget.addEventListener('keydown', handleJumpKeys); const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + let pendingCallbacks: Map = new Map(); + let callbackTimeout: ReturnType | null = null; + observersByDotNetObjectId[id] = { intersectionObserver, - mutationObserverBefore, - mutationObserverAfter, + resizeObserver, + refreshObservedElements, + scrollElement, + startConvergenceObserving, + onDispose: () => { + stopConvergenceObserving(); + resizeObserver.disconnect(); + keydownTarget.removeEventListener('keydown', handleJumpKeys); + if (callbackTimeout) { + clearTimeout(callbackTimeout); + callbackTimeout = null; + } + pendingCallbacks.clear(); + }, }; - function createSpacerMutationObserver(spacer: HTMLElement): MutationObserver { - // Without the use of thresholds, IntersectionObserver only detects binary changes in visibility, - // so if a spacer gets resized but remains visible, no additional callbacks will occur. By unobserving - // and reobserving spacers when they get resized, the intersection callback will re-run if they remain visible. - const observerOptions = { attributes: true }; - const mutationObserver = new MutationObserver((mutations: MutationRecord[], observer: MutationObserver): void => { - // Check if the spacer is still in the DOM - if (!spacer.isConnected) { - return; - } + function flushPendingCallbacks(): void { + if (pendingCallbacks.size === 0) return; + const entries = Array.from(pendingCallbacks.values()); + pendingCallbacks.clear(); + processIntersectionEntries(entries); + } - if (isValidTableElement(spacer.parentElement)) { - observer.disconnect(); - spacer.style.display = 'table-row'; - observer.observe(spacer, observerOptions); - } + function intersectionCallback(entries: IntersectionObserverEntry[]): void { + entries.forEach(entry => pendingCallbacks.set(entry.target, entry)); - intersectionObserver.unobserve(spacer); - intersectionObserver.observe(spacer); - }); + if (!callbackTimeout) { + flushPendingCallbacks(); - mutationObserver.observe(spacer, observerOptions); + callbackTimeout = setTimeout(() => { + callbackTimeout = null; + flushPendingCallbacks(); + }, THROTTLE_MS); + } + } - return mutationObserver; + function onSpacerAfterVisible(): void { + if (spacerAfter.offsetHeight === 0) { + if (convergingToBottom) { + convergingToBottom = false; + stopConvergenceObserving(); + } + return; + } + if (convergingToBottom) return; + + const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; + if (!atBottom && !pendingJumpToEnd) return; + + convergingToBottom = true; + startConvergenceObserving(); + if (pendingJumpToEnd) { + scrollElement.scrollTop = scrollElement.scrollHeight; + pendingJumpToEnd = false; + } } - function intersectionCallback(entries: IntersectionObserverEntry[]): void { - entries.forEach((entry): void => { - if (!entry.isIntersecting) { - return; + function onSpacerBeforeVisible(): void { + if (spacerBefore.offsetHeight === 0) { + if (convergingToTop) { + convergingToTop = false; + stopConvergenceObserving(); } + return; + } + if (convergingToTop) return; - // Check if the spacers are still in the DOM. They may have been removed if the component was disposed. - if (!spacerBefore.isConnected || !spacerAfter.isConnected) { - return; + const atTop = scrollElement.scrollTop < 1; + if (!atTop && !pendingJumpToStart) return; + + convergingToTop = true; + startConvergenceObserving(); + if (pendingJumpToStart) { + scrollElement.scrollTop = 0; + pendingJumpToStart = false; + } + } + + function processIntersectionEntries(entries: IntersectionObserverEntry[]): void { + // Check if the spacers are still in the DOM. They may have been removed if the component was disposed. + if (!spacerBefore.isConnected || !spacerAfter.isConnected) { + return; + } + + const intersectingEntries = entries.filter(entry => { + if (entry.isIntersecting) { + if (entry.target === spacerAfter) { + onSpacerAfterVisible(); + } else if (entry.target === spacerBefore) { + onSpacerBeforeVisible(); + } + return true; + } + if (entry.target === spacerAfter && convergingToBottom && spacerAfter.offsetHeight > 0) { + scrollElement.scrollTop = scrollElement.scrollHeight; + } else if (entry.target === spacerBefore && convergingToTop && spacerBefore.offsetHeight > 0) { + scrollElement.scrollTop = 0; } + return false; + }); + + if (intersectingEntries.length === 0) { + return; + } - // To compute the ItemSize, work out the separation between the two spacers. We can't just measure an individual element - // because each conceptual item could be made from multiple elements. Using getBoundingClientRect allows for the size to be - // a fractional value. It's important not to add or subtract any such fractional values (e.g., to subtract the 'top' of - // one item from the 'bottom' of another to get the distance between them) because floating point errors would cause - // scrolling glitches. - rangeBetweenSpacers.setStartAfter(spacerBefore); - rangeBetweenSpacers.setEndBefore(spacerAfter); - const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height; - const containerSize = entry.rootBounds?.height; + const { heightSum: measurementSum, heightCount: measurementCount, scaleFactor } = measureRenderedItems(spacerBefore, spacerAfter); + + rangeBetweenSpacers.setStartAfter(spacerBefore); + rangeBetweenSpacers.setEndBefore(spacerAfter); + const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor; + + intersectingEntries.forEach((entry): void => { + const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor; if (entry.target === spacerBefore) { - dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', entry.intersectionRect.top - entry.boundingClientRect.top, spacerSeparation, containerSize); + const spacerSize = (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor; + dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize, measurementSum, measurementCount); } else if (entry.target === spacerAfter && spacerAfter.offsetHeight > 0) { // When we first start up, both the "before" and "after" spacers will be visible, but it's only relevant to raise a // single event to load the initial data. To avoid raising two events, skip the one for the "after" spacer if we know // it's meaningless to talk about any overlap into it. - dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', entry.boundingClientRect.bottom - entry.intersectionRect.bottom, spacerSeparation, containerSize); + const spacerSize = (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor; + dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', spacerSize, spacerSeparation, containerSize, measurementSum, measurementCount); } }); } @@ -134,6 +351,21 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } +function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + const entry = observersByDotNetObjectId[id]; + if (entry) { + entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; + entry.startConvergenceObserving?.(); + } +} + +function refreshObservers(dotNetHelper: DotNet.DotNetObject): void { + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); + const entry = observersByDotNetObjectId[id]; + entry?.refreshObservedElements?.(); +} + function getObserversMapEntry(dotNetHelper: DotNet.DotNetObject): { observersByDotNetObjectId: {[id: number]: any }, id: number } { const dotNetHelperDispatcher = dotNetHelper['_callDispatcher']; const dotNetHelperId = dotNetHelper['_id']; @@ -151,8 +383,8 @@ function dispose(dotNetHelper: DotNet.DotNetObject): void { if (observers) { observers.intersectionObserver.disconnect(); - observers.mutationObserverBefore.disconnect(); - observers.mutationObserverAfter.disconnect(); + observers.resizeObserver?.disconnect(); + observers.onDispose?.(); delete observersByDotNetObjectId[id]; } diff --git a/src/Components/Web.JS/test/Virtualize.test.ts b/src/Components/Web.JS/test/Virtualize.test.ts new file mode 100644 index 000000000000..e54aa81a4a3f --- /dev/null +++ b/src/Components/Web.JS/test/Virtualize.test.ts @@ -0,0 +1,91 @@ +import { expect, test, describe, beforeEach, afterEach } from '@jest/globals'; +import { Virtualize } from '../src/Virtualize'; + +const { measureRenderedItems } = Virtualize; + +function createDOM(heights: number[]): { before: HTMLDivElement; after: HTMLDivElement } { + const container = document.createElement('div'); + document.body.appendChild(container); + const before = document.createElement('div'); + const after = document.createElement('div'); + container.appendChild(before); + + // Build N+1 fence: comment, item, comment, item, ..., comment + for (const h of heights) { + const comment = document.createComment('virtualize:item'); + container.appendChild(comment); + const item = document.createElement('div'); + item.getBoundingClientRect = () => ({ + height: h, width: 100, top: 0, left: 0, bottom: h, right: 100, + x: 0, y: 0, toJSON() { return this; }, + }); + container.appendChild(item); + } + if (heights.length > 0) { + container.appendChild(document.createComment('virtualize:item')); + } + + container.appendChild(after); + + // jsdom doesn't implement Range.getBoundingClientRect. + // Patch createRange to return a mock that sums item heights between start and end. + const origCreateRange = document.createRange.bind(document); + document.createRange = () => { + const range = origCreateRange(); + let startNode: Node | null = null; + const origSetStartAfter = range.setStartAfter.bind(range); + const origSetEndBefore = range.setEndBefore.bind(range); + range.setStartAfter = (node: Node) => { startNode = node; origSetStartAfter(node); }; + range.setEndBefore = (node: Node) => { + origSetEndBefore(node); + // Sum heights of element children between startNode and node + let totalHeight = 0; + if (startNode) { + for (let n = startNode.nextSibling; n && n !== node; n = n.nextSibling) { + if (n instanceof HTMLElement && n.getBoundingClientRect) { + totalHeight += n.getBoundingClientRect().height; + } + } + } + range.getBoundingClientRect = () => ({ + height: totalHeight, width: 100, top: 0, left: 0, bottom: totalHeight, right: 100, + x: 0, y: 0, toJSON() { return this; }, + } as DOMRect); + }; + return range; + }; + + return { before, after }; +} + +describe('measureRenderedItems', () => { + test('returns aggregated sum and count for valid items', () => { + const { before, after } = createDOM([40, 60]); + const result = measureRenderedItems(before, after); + expect(result.heightSum).toBe(100); + expect(result.heightCount).toBe(2); + }); + + test('includes zero-height items in count', () => { + const { before, after } = createDOM([50, 0, 30]); + const result = measureRenderedItems(before, after); + // Total height is 80 (50+0+30), all 3 items counted + expect(result.heightSum).toBe(80); + expect(result.heightCount).toBe(3); + }); + + test('returns zero for empty item list', () => { + const { before, after } = createDOM([]); + const result = measureRenderedItems(before, after); + expect(result.heightSum).toBe(0); + expect(result.heightCount).toBe(0); + }); + + test('returns zero for single item (needs at least 2 delimiters)', () => { + // Single item has 2 delimiters which is the minimum + const { before, after } = createDOM([42]); + const result = measureRenderedItems(before, after); + expect(result.heightSum).toBe(42); + expect(result.heightCount).toBe(1); + }); +}); diff --git a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs index 06087f04c97b..2ef922e647d0 100644 --- a/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs +++ b/src/Components/Web/src/Virtualization/IVirtualizeJsCallbacks.cs @@ -5,6 +5,6 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization; internal interface IVirtualizeJsCallbacks { - void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize); - void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize); + void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount); + void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount); } diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 8e2d84f2f11b..0a18365111bb 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -43,10 +43,14 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private float _itemSize; + private float _lastSetItemSize; + private IEnumerable? _loadedItems; private CancellationTokenSource? _refreshCts; + private bool _skipNextDistributionRefresh; + private Exception? _refreshException; private ItemsProviderDelegate _itemsProvider = default!; @@ -59,11 +63,17 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private bool _loading; + internal float _totalMeasuredHeight; + + internal int _measuredItemCount; + + internal bool _pendingScrollToBottom; + [Inject] private IJSRuntime JSRuntime { get; set; } = default!; /// - /// Gets or sets the item template for the list. + /// Gets or sets the item template for the list. See . /// [Parameter] public RenderFragment? ChildContent { get; set; } @@ -112,7 +122,7 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I /// in the page. /// [Parameter] - public int OverscanCount { get; set; } = 3; + public int OverscanCount { get; set; } = 15; /// /// Gets or sets the tag name of the HTML element that will be used as the virtualization spacer. @@ -148,6 +158,8 @@ public async Task RefreshDataAsync() // We don't auto-render after this operation because in the typical use case, the // host component calls this from one of its lifecycle methods, and will naturally // re-render afterwards anyway. It's not desirable to re-render twice. + _totalMeasuredHeight = 0; + _measuredItemCount = 0; await RefreshDataCoreAsync(renderOnSuccess: false); } @@ -165,6 +177,15 @@ protected override void OnParametersSet() _itemSize = ItemSize; } + // Without this reset, visibleItemCapacity is under/over-estimated after a size change, + // causing extra provider calls that may never complete (e.g., async providers). + if (_lastSetItemSize != ItemSize) + { + _lastSetItemSize = ItemSize; + _totalMeasuredHeight = 0; + _measuredItemCount = 0; + } + if (ItemsProvider != null) { if (Items != null) @@ -208,6 +229,18 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _jsInterop = new VirtualizeJsInterop(this, JSRuntime); await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter); } + + if (_pendingScrollToBottom && _jsInterop is not null) + { + _pendingScrollToBottom = false; + await _jsInterop.ScrollToBottomAsync(); + } + + // After render the set of items could change. Tell JS to refresh ResizeObserver. + if (!firstRender && _jsInterop is not null) + { + await _jsInterop.RefreshObserversAsync(); + } } /// @@ -257,13 +290,19 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenRegion(5); - // Render the loaded items. + // Render items with comment delimiters for JS height measurement (N+1 fence pattern). foreach (var item in itemsToShow) { + builder.AddMarkupContent(0, ""); _itemTemplate(item)(builder); _lastRenderedItemCount++; } + if (_lastRenderedItemCount > 0) + { + builder.AddMarkupContent(1, ""); + } + renderIndex += _lastRenderedItemCount; builder.CloseRegion(); @@ -292,22 +331,39 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } private string GetSpacerStyle(int itemsInSpacer, int numItemsGapAbove) - => numItemsGapAbove == 0 - ? GetSpacerStyle(itemsInSpacer) - : $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * _itemSize).ToString(CultureInfo.InvariantCulture)}px);"; + { + var avgHeight = GetItemHeight(); + return numItemsGapAbove == 0 + ? GetSpacerStyle(itemsInSpacer) + : $"height: {(itemsInSpacer * avgHeight).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * avgHeight).ToString(CultureInfo.InvariantCulture)}px);"; + } private string GetSpacerStyle(int itemsInSpacer) - => $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"; + => $"height: {(itemsInSpacer * GetItemHeight()).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;"; + + private float GetItemHeight() + => _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize; - void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) + private bool ProcessMeasurements(float measuredItemHeightSum, int measuredItemCount) { - CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity); + if (measuredItemCount <= 0) + { + return false; + } + + _totalMeasuredHeight += measuredItemHeightSum; + _measuredItemCount += measuredItemCount; + return true; + } + + void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount) + { + ProcessMeasurements(measuredItemHeightSum, measuredItemCount); - // Since we know the before spacer is now visible, we absolutely have to slide the window up - // by at least one element. If we're not doing that, the previous item size info we had must - // have been wrong, so just move along by one in that case to trigger an update and apply the - // new size info. - if (itemsBefore == _itemsBefore && itemsBefore > 0) + CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity); + + // Slide window up by at least one if spacer is visible but position unchanged. + if (_lastRenderedItemCount > 0 && itemsBefore == _itemsBefore && itemsBefore > 0) { itemsBefore--; } @@ -315,25 +371,31 @@ void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacer UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } - void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize) + void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount) { - CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity); + var hadNewMeasurements = ProcessMeasurements(measuredItemHeightSum, measuredItemCount); + + CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity); var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity); - // Since we know the after spacer is now visible, we absolutely have to slide the window down - // by at least one element. If we're not doing that, the previous item size info we had must - // have been wrong, so just move along by one in that case to trigger an update and apply the - // new size info. - if (itemsBefore == _itemsBefore && itemsBefore < _itemCount - visibleItemCapacity) + // Slide window down by at least one if spacer is visible but position unchanged. + if (_lastRenderedItemCount > 0 && itemsBefore == _itemsBefore && itemsBefore < _itemCount - visibleItemCapacity) { itemsBefore++; } + // When we're at the very bottom and new measurements arrived, + // scroll to bottom so the viewport stays pinned while items converge. + if (itemsAfter == 0 && hadNewMeasurements) + { + _pendingScrollToBottom = true; + } + UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity); } - private void CalcualteItemDistribution( + private void CalculateItemDistribution( float spacerSize, float spacerSeparation, float containerSize, @@ -366,8 +428,15 @@ private void CalcualteItemDistribution( // the user has set a very low MaxItemCount and we end up in an infinite loading loop. maxItemCount += OverscanCount * 2; - itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / _itemSize) - OverscanCount); - visibleItemCapacity = (int)Math.Ceiling(containerSize / _itemSize) + 2 * OverscanCount; + // Use average measured height for calculations, falling back to _itemSize to avoid division by zero + var effectiveItemSize = GetItemHeight(); + if (effectiveItemSize <= 0 || float.IsNaN(effectiveItemSize) || float.IsInfinity(effectiveItemSize)) + { + effectiveItemSize = _itemSize; + } + + itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / effectiveItemSize) - OverscanCount); + visibleItemCapacity = (int)Math.Ceiling(containerSize / effectiveItemSize) + 2 * OverscanCount; unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount); visibleItemCapacity -= unusedItemCapacity; } @@ -387,12 +456,30 @@ private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity, in _itemsBefore = itemsBefore; _visibleItemCapacity = visibleItemCapacity; _unusedItemCapacity = unusedItemCapacity; - var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true); - if (!refreshTask.IsCompleted) + // After a successful data load, the ResizeObserver→IntersectionObserver cycle + // re-triggers with refined measurements. This one-shot flag skips the single + // redundant provider call that follows. At end-of-list, don't skip: refined + // capacity may reveal that more items are needed to fill the viewport. + var skipRefresh = _skipNextDistributionRefresh + && _loadedItems != null + && _loadedItemsStartIndex == _itemsBefore + && _itemsBefore + visibleItemCapacity < _itemCount; + _skipNextDistributionRefresh = false; + + if (skipRefresh) { StateHasChanged(); } + else + { + var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true); + + if (!refreshTask.IsCompleted) + { + StateHasChanged(); + } + } } } @@ -429,6 +516,7 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess) _loadedItems = result.Items; _loadedItemsStartIndex = request.StartIndex; _loading = false; + _skipNextDistributionRefresh = request.Count > 0; if (renderOnSuccess) { diff --git a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs index 0d1ed705ec8e..59b6c9168022 100644 --- a/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs +++ b/src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs @@ -31,15 +31,25 @@ public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementRef } [JSInvokable] - public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize) + public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount) { - _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize); + _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, measuredItemHeightSum, measuredItemCount); } [JSInvokable] - public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize) + public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount) { - _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize); + _owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, measuredItemHeightSum, measuredItemCount); + } + + public ValueTask ScrollToBottomAsync() + { + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.scrollToBottom", _selfReference); + } + + public ValueTask RefreshObserversAsync() + { + return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.refreshObservers", _selfReference); } public async ValueTask DisposeAsync() diff --git a/src/Components/Web/test/Virtualization/VirtualizeTest.cs b/src/Components/Web/test/Virtualization/VirtualizeTest.cs index 89819c4bf401..57f3a43566e6 100644 --- a/src/Components/Web/test/Virtualization/VirtualizeTest.cs +++ b/src/Components/Web/test/Virtualization/VirtualizeTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.Extensions.DependencyInjection; @@ -92,13 +93,615 @@ public async Task Virtualize_DispatchesExceptionsFromItemsProviderThroughRendere Assert.NotNull(renderedVirtualize); // Simulate a JS spacer callback. - ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(10f, 50f, 100f); + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(10f, 50f, 100f, 0f, 0); // Validate that the exception is dispatched through the renderer. var ex = await Assert.ThrowsAsync(async () => await testRenderer.RenderRootComponentAsync(componentId)); Assert.Equal("Thrown from items provider.", ex.Message); } + [Fact] + public async Task Virtualize_AcceptsItemMeasurementsFromSpacerCallback() + { + Virtualize renderedVirtualize = null; + var itemsProviderCallCount = 0; + + ValueTask> countingItemsProvider(ItemsProviderRequest request) + { + itemsProviderCallCount++; + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100)); + } + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualize(50f, countingItemsProvider, null, virtualize => renderedVirtualize = virtualize) + }; + + 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 initialCallCount = itemsProviderCallCount; + + // Simulate JS callback with pre-aggregated measurements (sum and count) + // Heights: 30 + 70 + 50 = 150, count = 3 + + await testRenderer.Dispatcher.InvokeAsync(() => + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, 150f, 3)); + + Assert.True(itemsProviderCallCount > initialCallCount, + "ItemsProvider should be called after spacer callback with measurements"); + } + + [Fact] + public async Task Virtualize_MeasurementsUpdateRunningAverage() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 80f, 500f, 80f, 2)); + + Assert.Equal(80f, virtualize._totalMeasuredHeight); + Assert.Equal(2, virtualize._measuredItemCount); + } + + [Fact] + public async Task Virtualize_NullMeasurementsUseDefaultItemSize() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 40f, totalItems: 100); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 200f, 400f, 0f, 0)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 200f, 400f, 0f, 0)); + } + + [Fact] + public async Task Virtualize_ZeroLengthMeasurementsDoNotCorruptAverage() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 0f, 0)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 0f, 0)); + + Assert.Equal(100f, virtualize._totalMeasuredHeight); + Assert.Equal(2, virtualize._measuredItemCount); + } + + [Fact] + public async Task Virtualize_BimodalMeasurementsProduceValidAverage() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 200); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + for (int i = 0; i < 2; i++) + { + // Bimodal: 30+300+30+300+30+300 = 990, count = 6 + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 990f, 600f, 990f, 6)); + } + } + + [Fact] + public async Task Virtualize_VerySmallMeasurementsDoNotCauseExcessiveItemCounts() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 10000); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 5f, 1000f, 5f, 5)); + } + + [Fact] + public async Task Virtualize_LargeMeasurementsProduceValidDistribution() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 4000f, 500f, 4000f, 2)); + } + + [Fact] + public async Task Virtualize_OnBeforeSpacerVisible_ProcessesMeasurementsBeforeCalculation() + { + var requests = new List(); + + ValueTask> trackingProvider(ItemsProviderRequest request) + { + requests.Add(request); + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100)); + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, customProvider: trackingProvider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + var countBefore = requests.Count; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible(100f, 300f, 500f, 150f, 3)); + + Assert.True(requests.Count > countBefore, + "ItemsProvider should be called when before spacer becomes visible with measurements"); + } + + [Fact] + public async Task Virtualize_NonZeroStartIndex_ItemsProviderReceivesCorrectStartIndex() + { + var requests = new List(); + + ValueTask> trackingProvider(ItemsProviderRequest request) + { + requests.Add(request); + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 500 - request.StartIndex)), + 500)); + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 500, customProvider: trackingProvider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 500f, 500f, 150f, 3)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible(5000f, 500f, 500f, 100f, 2)); + + Assert.Contains(requests, r => r.StartIndex > 0); + } + + [Fact] + public async Task Virtualize_NaNMeasurementsDoNotCrashComponent() + { + var requests = new List(); + + ValueTask> trackingProvider(ItemsProviderRequest request) + { + requests.Add(request); + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100)); + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, customProvider: trackingProvider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + var countAfterBaseline = requests.Count; + + // NaN/invalid values are filtered in JS before aggregation. + // Only the valid measurement (30f) is included in the sum. + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 30f, 1)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible(50f, 100f, 500f, 100f, 2)); + + Assert.True(requests.Count > countAfterBaseline, + "Component should still process callbacks after receiving NaN measurements"); + } + + [Fact] + public async Task Virtualize_NegativeMeasurementsDoNotCrashComponent() + { + var requests = new List(); + + ValueTask> trackingProvider(ItemsProviderRequest request) + { + requests.Add(request); + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100)); + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, customProvider: trackingProvider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + var countAfterBaseline = requests.Count; + + // Negative values are filtered in JS before aggregation. + // Only the valid measurement (50f) is included in the sum. + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 50f, 1)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible(50f, 100f, 500f, 100f, 2)); + + Assert.True(requests.Count > countAfterBaseline, + "Component should still process callbacks after receiving negative measurements"); + } + + [Fact] + public async Task Virtualize_InfinityMeasurementsDoNotCrashComponent() + { + var requests = new List(); + + ValueTask> trackingProvider(ItemsProviderRequest request) + { + requests.Add(request); + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100)); + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, customProvider: trackingProvider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 100f, 2)); + var countAfterBaseline = requests.Count; + + // Infinity values are filtered in JS before aggregation. + // No valid measurements means count=0. + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 100f, 500f, 0f, 0)); + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible(50f, 100f, 500f, 100f, 2)); + + Assert.True(requests.Count > countAfterBaseline, + "Component should still process callbacks after receiving infinity measurements"); + } + + [Fact] + public async Task Virtualize_RendersCommentDelimitersForJsMeasurement() + { + Virtualize renderedVirtualize = null; + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, new List { 1, 2, 3 }, + captureRenderedVirtualize: virtualize => renderedVirtualize = virtualize) + }; + + 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); + + await testRenderer.Dispatcher.InvokeAsync(() => + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, 0f, 0)); + + var hasCommentDelimiters = testRenderer.Batches + .SelectMany(b => b.ReferenceFrames) + .Any(f => f.FrameType == RenderTreeFrameType.Markup + && f.MarkupContent == ""); + + Assert.True(hasCommentDelimiters, + "Items should be delimited by comments for JS measurement"); + } + + [Fact] + public async Task Virtualize_TableSpacerElement_UsesCommentDelimitersNotWrapperElements() + { + Virtualize renderedVirtualize = null; + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, new List { 1, 2, 3 }, + captureRenderedVirtualize: virtualize => renderedVirtualize = virtualize, + spacerElement: "tr") + }; + + 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); + + await testRenderer.Dispatcher.InvokeAsync(() => + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(0f, 150f, 500f, 0f, 0)); + + var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList(); + + var hasCommentDelimiters = referenceFrames + .Any(f => f.FrameType == RenderTreeFrameType.Markup + && f.MarkupContent == ""); + + var hasTrSpacers = referenceFrames + .Any(f => f.FrameType == RenderTreeFrameType.Element && f.ElementName == "tr"); + + Assert.True(hasCommentDelimiters, + "Items should use comment delimiters, not wrapper elements"); + Assert.True(hasTrSpacers, + "Spacer elements should use 'tr' tag when SpacerElement='tr'"); + } + + [Fact] + public async Task Virtualize_RefreshDataAsync_ResetsRunningAverage() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + for (int i = 0; i < 10; i++) + { + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 90f, 500f, 90f, 3)); + } + + Assert.True(virtualize._totalMeasuredHeight > 0); + Assert.True(virtualize._measuredItemCount > 0); + + await renderer.Dispatcher.InvokeAsync(() => virtualize.RefreshDataAsync()); + + Assert.Equal(0f, virtualize._totalMeasuredHeight); + Assert.Equal(0, virtualize._measuredItemCount); + } + + [Fact] + public async Task Virtualize_ScrollToBottom_SetWhenAtEndWithNewMeasurements() + { + var mockJs = new Mock(MockBehavior.Loose); + + Virtualize renderedVirtualize = null; + + ValueTask> provider(ItemsProviderRequest request) + { + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100)); + } + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualize(50f, provider, null, + virtualize => renderedVirtualize = virtualize) + }; + + var serviceProvider = new ServiceCollection() + .AddTransient((sp) => mockJs.Object) + .BuildServiceProvider(); + + var testRenderer = new TestRenderer(serviceProvider); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + + await testRenderer.RenderRootComponentAsync(componentId); + Assert.NotNull(renderedVirtualize); + + // spacerSize=0 means at the very bottom; new measurements should trigger scrollToBottom + await testRenderer.Dispatcher.InvokeAsync(() => + ((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible( + 0f, 500f, 500f, 100f, 2)); + + var scrollToBottomCalled = mockJs.Invocations.Any(i => + i.Arguments.Count > 0 && + i.Arguments[0] is string id && + id.Contains("scrollToBottom")); + + Assert.True(scrollToBottomCalled || renderedVirtualize._pendingScrollToBottom, + "scrollToBottom should either be called via JS interop or be pending"); + } + + [Fact] + public async Task Virtualize_ScrollToBottom_NotSetWhenNotAtEnd() + { + var (virtualize, renderer) = await CreateRenderedVirtualize(itemSize: 50f, totalItems: 100); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + // spacerSize=5000 means many items remain after the viewport + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(5000f, 1000f, 500f, 100f, 2)); + + Assert.False(virtualize._pendingScrollToBottom); + } + + [Fact] + public async Task Virtualize_FirstRender_DoesNotShiftStartIndexAwayFromZero() + { + var requests = new List(); + + ValueTask> trackingProvider(ItemsProviderRequest request) + { + requests.Add(request); + return ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, 100 - request.StartIndex)), + 100)); + } + + var (virtualize, renderer) = await CreateRenderedVirtualize( + itemSize: 50f, totalItems: 100, customProvider: trackingProvider); + var callbacks = (IVirtualizeJsCallbacks)virtualize; + + var callCountAfterMount = requests.Count; + + await renderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible(50f, 500f, 500f, 0f, 0)); + + Assert.Equal(callCountAfterMount + 1, requests.Count); + + var lastRequest = requests[^1]; + Assert.Equal(0, lastRequest.StartIndex); + } + + [Fact] + public async Task Virtualize_BothSpacersVisible_SmallItemCountDoesNotCrash() + { + Virtualize renderedVirtualize = null; + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, new List { 1, 2, 3 }, + captureRenderedVirtualize: 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; + + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, 150f, 3)); + + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnBeforeSpacerVisible(0f, 150f, 1000f, 0f, 0)); + + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 150f, 1000f, 0f, 0)); + + Assert.Equal(3, renderedVirtualize._measuredItemCount); + } + + [Fact] + public async Task Virtualize_FixedItems_MeasurementsAccumulateWithoutBreakingRendering() + { + Virtualize renderedVirtualize = null; + + var items = Enumerable.Range(1, 20).ToList(); + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualizeWithContent(50f, items, + captureRenderedVirtualize: 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; + + Assert.Equal(0f, renderedVirtualize._totalMeasuredHeight); + Assert.Equal(0, renderedVirtualize._measuredItemCount); + + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 1000f, 500f, 150f, 3)); + + Assert.Equal(150f, renderedVirtualize._totalMeasuredHeight); + Assert.Equal(3, renderedVirtualize._measuredItemCount); + } + + [Fact] + public async Task Virtualize_RapidScrollCancellation_StaleRequestsCancelled() + { + var pendingCalls = new List<(ItemsProviderRequest request, TaskCompletionSource> tcs)>(); + + ValueTask> delayedProvider(ItemsProviderRequest request) + { + var tcs = new TaskCompletionSource>(); + pendingCalls.Add((request, tcs)); + return new ValueTask>(tcs.Task); + } + + Virtualize renderedVirtualize = null; + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualize(50f, delayedProvider, 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; + + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 0f, 500f, 50f, 1)); + + Assert.Single(pendingCalls); + var firstCall = pendingCalls[0]; + + await testRenderer.Dispatcher.InvokeAsync(() => + callbacks.OnAfterSpacerVisible(0f, 0f, 1000f, 50f, 1)); + + Assert.Equal(2, pendingCalls.Count); + var secondCall = pendingCalls[1]; + + Assert.True(firstCall.request.CancellationToken.IsCancellationRequested); + Assert.False(secondCall.request.CancellationToken.IsCancellationRequested); + + Assert.Equal(2, renderedVirtualize._measuredItemCount); + Assert.Equal(100f, renderedVirtualize._totalMeasuredHeight); + foreach (var call in pendingCalls.Where(c => !c.tcs.Task.IsCompleted)) + { + call.tcs.TrySetResult(new ItemsProviderResult(Array.Empty(), 0)); + } + } + + private async Task<(Virtualize virtualize, TestRenderer renderer)> CreateRenderedVirtualize( + float itemSize, + int totalItems, + ItemsProviderDelegate customProvider = null) + { + Virtualize renderedVirtualize = null; + + ItemsProviderDelegate provider = customProvider ?? ((ItemsProviderRequest request) => + ValueTask.FromResult(new ItemsProviderResult( + Enumerable.Range(request.StartIndex, Math.Min(request.Count, totalItems - request.StartIndex)), + totalItems))); + + var rootComponent = new VirtualizeTestHostcomponent + { + InnerContent = BuildVirtualize(itemSize, provider, null, virtualize => renderedVirtualize = virtualize) + }; + + 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); + + return (renderedVirtualize, testRenderer); + } + private ValueTask> EmptyItemsProvider(ItemsProviderRequest request) => ValueTask.FromResult(new ItemsProviderResult(Enumerable.Empty(), 0)); @@ -125,6 +728,62 @@ private RenderFragment BuildVirtualize( builder.CloseComponent(); }; + private RenderFragment BuildVirtualizeWithContent( + float itemSize, + ICollection items, + Action> captureRenderedVirtualize = null, + string spacerElement = "div") + => builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "ItemSize", itemSize); + builder.AddComponentParameter(2, "Items", items); + builder.AddComponentParameter(3, "SpacerElement", spacerElement); + builder.AddComponentParameter(4, "ChildContent", (RenderFragment)(item => b => + { + b.OpenElement(0, "span"); + b.AddContent(1, item.ToString(System.Globalization.CultureInfo.InvariantCulture)); + b.CloseElement(); + })); + + if (captureRenderedVirtualize != null) + { + builder.AddComponentReferenceCapture(5, component => + captureRenderedVirtualize(component as Virtualize)); + } + + builder.CloseComponent(); + }; + + private RenderFragment BuildVirtualizeWithMultiRootContent( + float itemSize, + ICollection items, + Action> captureRenderedVirtualize = null) + => builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "ItemSize", itemSize); + builder.AddComponentParameter(2, "Items", items); + builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(item => b => + { + // Two root elements per item + b.OpenElement(0, "div"); + b.AddContent(1, $"Part A of {item.ToString(System.Globalization.CultureInfo.InvariantCulture)}"); + b.CloseElement(); + b.OpenElement(2, "div"); + b.AddContent(3, $"Part B of {item.ToString(System.Globalization.CultureInfo.InvariantCulture)}"); + b.CloseElement(); + })); + + if (captureRenderedVirtualize != null) + { + builder.AddComponentReferenceCapture(4, component => + captureRenderedVirtualize(component as Virtualize)); + } + + builder.CloseComponent(); + }; + private class VirtualizeTestHostcomponent : AutoRenderComponent { public RenderFragment InnerContent { get; set; } diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 2e155a8bd809..44220f466051 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -29,6 +29,11 @@ protected override void InitializeAsyncCore() Navigate(ServerPathBase); } + private int GetElementCount(By by) => Browser.FindElements(by).Count; + + private int GetElementCount(ISearchContext container, string cssSelector) + => container.FindElements(By.CssSelector(cssSelector)).Count; + [Fact] public void AlwaysFillsVisibleCapacity_Sync() { @@ -209,8 +214,7 @@ public void CanUseViewportAsContainer() Browser.ExecuteJavaScript("window.scrollTo(0, document.body.scrollHeight);"); // Validate that the scroll event completed successfully - var lastElement = Browser.Exists(By.Id("999")); - Browser.True(() => lastElement.Displayed); + Browser.True(() => Browser.Exists(By.Id("999")).Displayed); // Validate that the top spacer has expanded. Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetDomAttribute("style")); @@ -258,21 +262,52 @@ public void CanRenderHtmlTable() // We can override the tag name of the spacer Assert.Equal("tr", topSpacer.TagName.ToLowerInvariant()); Assert.Equal("tr", bottomSpacer.TagName.ToLowerInvariant()); - Assert.Contains(expectedInitialSpacerStyle, topSpacer.GetDomAttribute("style")); + Browser.True(() => topSpacer.GetDomAttribute("style").Contains(expectedInitialSpacerStyle)); Assert.Contains("true", topSpacer.GetDomAttribute("aria-hidden")); Assert.Contains("true", bottomSpacer.GetDomAttribute("aria-hidden")); // Check scrolling document element works Browser.DoesNotExist(By.Id("row-999")); Browser.ExecuteJavaScript("window.scrollTo(0, document.body.scrollHeight);"); - var lastElement = Browser.Exists(By.Id("row-999")); - Browser.True(() => lastElement.Displayed); + Browser.True(() => Browser.Exists(By.Id("row-999")).Displayed); // Validate that the top spacer has expanded, and bottom one has collapsed Browser.False(() => topSpacer.GetDomAttribute("style").Contains(expectedInitialSpacerStyle)); Assert.Contains(expectedInitialSpacerStyle, bottomSpacer.GetDomAttribute("style")); } + [Fact] + public void VariableHeight_HtmlTable_DoesNotProduceNestedTrElements() + { + Browser.MountTestComponent(); + + Browser.Equal("Total items: 500", () => Browser.Exists(By.Id("vht-total-items")).Text); + Browser.True(() => Browser.Exists(By.Id("vht-row-0")).Displayed); + + var topSpacer = Browser.Exists(By.CssSelector("#variable-height-table > tbody > :first-child")); + Assert.Equal("tr", topSpacer.TagName.ToLowerInvariant()); + + var js = (IJavaScriptExecutor)Browser; + + // With comment-based approach, the template provides directly — no wrapper elements. + // Verify no nested inside (which would indicate invalid table structure). + var nestedTrCount = (long)js.ExecuteScript( + "return document.querySelectorAll('#variable-height-table tr > tr').length;"); + Assert.Equal(0, nestedTrCount); + + // Verify rendered item rows exist (user template containing ) + var itemRowCount = (long)js.ExecuteScript( + "return document.querySelectorAll('#variable-height-table tbody tr td').length;"); + Assert.True(itemRowCount > 0, "Should have elements containing cells"); + + Browser.ExecuteJavaScript("window.scrollTo(0, document.body.scrollHeight);"); + Browser.True(() => Browser.Exists(By.Id("vht-row-499")).Displayed); + + var nestedTrCountAfterScroll = (long)js.ExecuteScript( + "return document.querySelectorAll('#variable-height-table tr > tr').length;"); + Assert.Equal(0, nestedTrCountAfterScroll); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -658,14 +693,24 @@ List GetVisibleItemIndices() var list = new List(elements.Count); foreach (var el in elements) { - var text = el.Text; - if (text.StartsWith("Item ", StringComparison.Ordinal)) + try { - if (int.TryParse(text.AsSpan(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + var text = el.Text; + if (text.StartsWith("Item ", StringComparison.Ordinal)) { - list.Add(value); + if (int.TryParse(text.AsSpan(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + { + list.Add(value); + } } } + catch (StaleElementReferenceException) + { + // With variable-height support, item heights are measured and reported back to .NET, + // which recalculates spacer heights. This causes more frequent re-renders during scroll, + // and DOM elements can be replaced mid-iteration. Skipping stale elements is fine since + // the test collects indices across multiple scroll positions. + } } return list; } @@ -695,6 +740,1097 @@ private static void ScrollLeftToEnd(IWebDriver Browser, IWebElement elem) js.ExecuteScript("arguments[0].scrollLeft = arguments[0].scrollWidth", elem); } + /// + /// Jumps to end using a single End key press, then waits for scroll position to stabilize. + /// With the measurement-based scroll compensation in Virtualize, a single End press converges + /// to the true bottom automatically. For async mode, handles loading data when placeholders appear. + /// + private void JumpToEndWithStabilization( + IWebElement container, + Func hasPlaceholders, + Action loadData, + int maxLoadRounds = 10) + { + var js = (IJavaScriptExecutor)Browser; + + // Ensure container has focus for keyboard input + container.Click(); + + // Single End key press — the scroll compensation in Virtualize should converge to the bottom + container.SendKeys(Keys.End); + + var endPollCount = 0; + var endDiagnostics = new System.Text.StringBuilder(); + try + { + Browser.True(() => + { + endPollCount++; + if (hasPlaceholders?.Invoke() == true) + { + endDiagnostics.AppendLine(CultureInfo.InvariantCulture, $" poll #{endPollCount}: placeholders visible, loading data..."); + loadData?.Invoke(); + return false; + } + + // Check if spacerAfter has essentially zero height (truly at the end). + var metrics = (IReadOnlyDictionary)js.ExecuteScript( + "var c = arguments[0]; var spacers = c.querySelectorAll('[aria-hidden]');" + + "return { spacerAfterHeight: spacers.length >= 2 ? spacers[spacers.length - 1].offsetHeight : 999," + + " scrollTop: c.scrollTop, scrollHeight: c.scrollHeight, clientHeight: c.clientHeight };", + container); + var spacerAfterHeight = Convert.ToDouble(metrics["spacerAfterHeight"], CultureInfo.InvariantCulture); + var scrollTop = Convert.ToDouble(metrics["scrollTop"], CultureInfo.InvariantCulture); + var scrollHeight = Convert.ToDouble(metrics["scrollHeight"], CultureInfo.InvariantCulture); + var clientHeight = Convert.ToDouble(metrics["clientHeight"], CultureInfo.InvariantCulture); + var remaining = scrollHeight - scrollTop - clientHeight; + endDiagnostics.AppendLine(CultureInfo.InvariantCulture, $" poll #{endPollCount}: spacerAfter={spacerAfterHeight:F1}, scrollTop={scrollTop:F1}, scrollHeight={scrollHeight:F1}, clientHeight={clientHeight:F1}, remaining={remaining:F1}"); + + return spacerAfterHeight < 1; + }); + } + catch (Exception ex) + { + throw new Exception( + $"JumpToEnd convergence failed after {endPollCount} polls.\n{endDiagnostics}", ex); + } + + // Wait for scroll position to stabilize (compensation convergence) + WaitForScrollStabilization(container); + } + + /// + /// Jumps to start using a single Home key press, then waits for scroll to reach the top. + /// For async mode, handles loading data when placeholders appear. + /// + private void JumpToStartWithStabilization( + IWebElement container, + Func hasPlaceholders, + Action loadData, + Func isFirstItemVisible, + int maxLoadRounds = 10) + { + var js = (IJavaScriptExecutor)Browser; + + // Ensure container has focus for keyboard input + container.Click(); + + // Single Home key press + container.SendKeys(Keys.Home); + + // Wait until we truly reach the start of the list + var startPollCount = 0; + var startDiagnostics = new System.Text.StringBuilder(); + try + { + Browser.True(() => + { + startPollCount++; + // Handle async loading: if placeholders are visible, load data + if (hasPlaceholders?.Invoke() == true) + { + startDiagnostics.AppendLine(CultureInfo.InvariantCulture, $" poll #{startPollCount}: placeholders visible, loading data..."); + loadData?.Invoke(); + return false; + } + + // Check if spacerBefore has essentially zero height (truly at the start). + var metrics = (IReadOnlyDictionary)js.ExecuteScript( + "var c = arguments[0]; var spacers = c.querySelectorAll('[aria-hidden]');" + + "return { spacerBeforeHeight: spacers.length >= 1 ? spacers[0].offsetHeight : 999," + + " scrollTop: c.scrollTop, scrollHeight: c.scrollHeight, clientHeight: c.clientHeight };", + container); + var spacerBeforeHeight = Convert.ToDouble(metrics["spacerBeforeHeight"], CultureInfo.InvariantCulture); + var scrollTop = Convert.ToDouble(metrics["scrollTop"], CultureInfo.InvariantCulture); + var scrollHeight = Convert.ToDouble(metrics["scrollHeight"], CultureInfo.InvariantCulture); + var clientHeight = Convert.ToDouble(metrics["clientHeight"], CultureInfo.InvariantCulture); + startDiagnostics.AppendLine(CultureInfo.InvariantCulture, $" poll #{startPollCount}: spacerBefore={spacerBeforeHeight:F1}, scrollTop={scrollTop:F1}, scrollHeight={scrollHeight:F1}, clientHeight={clientHeight:F1}"); + + return spacerBeforeHeight < 1; + }); + } + catch (Exception ex) + { + throw new Exception( + $"JumpToStart convergence failed after {startPollCount} polls.\n{startDiagnostics}", ex); + } + + // Wait for scroll position to stabilize + WaitForScrollStabilization(container); + } + + /// + /// Waits for the scroll position to stop changing (stabilize within 1px across consecutive checks). + /// + private void WaitForScrollStabilization(IWebElement container) + { + var js = (IJavaScriptExecutor)Browser; + double lastScrollTop = -1; + Browser.True(() => + { + var current = Convert.ToDouble(js.ExecuteScript("return arguments[0].scrollTop;", container), CultureInfo.InvariantCulture); + if (Math.Abs(current - lastScrollTop) < 1 && lastScrollTop >= 0) + { + return true; + } + + lastScrollTop = current; + return false; + }); + } + + [Theory] + [InlineData(false)] // sync + [InlineData(true)] // async + public void VariableHeight_CanJumpToEndAndStart(bool useAsync) + { + IWebElement container; + IWebElement finishLoadingButton = null; + string itemClass, placeholderClass, firstItemId, lastItemId; + + if (useAsync) + { + Browser.MountTestComponent(); + container = Browser.Exists(By.Id("async-variable-container")); + finishLoadingButton = Browser.Exists(By.Id("finish-loading")); + itemClass = ".async-variable-item"; + placeholderClass = ".async-variable-placeholder"; + firstItemId = "async-variable-item-0"; + lastItemId = "async-variable-item-999"; + + finishLoadingButton.Click(); + } + else + { + Browser.MountTestComponent(); + container = Browser.Exists(By.Id("variable-height-container")); + itemClass = ".variable-height-item"; + placeholderClass = null; + firstItemId = "variable-item-0"; + lastItemId = "variable-item-999"; + } + + Browser.True(() => GetElementCount(container, itemClass) > 0); + + // Jump to end + var hasPlaceholders = useAsync ? () => GetElementCount(container, placeholderClass) > 0 : (Func)null; + var loadData = useAsync ? () => finishLoadingButton.Click() : (Action)null; + JumpToEndWithStabilization(container, hasPlaceholders, loadData); + Browser.True(() => GetElementCount(container, itemClass) > 0); + Browser.True(() => container.FindElements(By.Id(lastItemId)).Count > 0); + + // Jump back to start using shared helper + JumpToStartWithStabilization( + container, + hasPlaceholders, + loadData, + () => container.FindElements(By.Id(firstItemId)).Count > 0); + Browser.True(() => GetElementCount(container, itemClass) > 0); + Browser.True(() => container.FindElements(By.Id(firstItemId)).Count > 0); + } + + [Fact] + public void VariableHeight_ItemsRenderWithCorrectHeights() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("variable-height-container")); + Browser.True(() => GetElementCount(container, ".variable-height-item") > 0); + + // Check that item 0 has the expected height (20px from our test data: 20 + (0*37%1981) = 20) + var item0 = container.FindElement(By.Id("variable-item-0")); + var style0 = item0.GetDomAttribute("style"); + Assert.Contains("height: 20px", style0); + + // Check that item 1 has the expected height (57px from our test data: 20 + (1*37%1981) = 57) + var item1 = container.FindElement(By.Id("variable-item-1")); + var style1 = item1.GetDomAttribute("style"); + Assert.Contains("height: 57px", style1); + } + + [Fact] + public void DynamicContent_ItemHeightChangesUpdateLayout() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + var status = Browser.Exists(By.Id("status")); + + // Wait for items to render + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Get position of item 3 before expansion + var item3TopBefore = container.FindElement(By.CssSelector("[data-index='3']")).Location.Y; + + // Expand item 2 + Browser.Exists(By.Id("expand-item-2")).Click(); + Browser.Equal("Item 2 expanded via button", () => status.Text); + + // Verify item 2 now has expanded content + var item2 = container.FindElement(By.CssSelector("[data-index='2']")); + Assert.Single(item2.FindElements(By.CssSelector(".expanded-content"))); + + // Verify item 3 moved down (not overlapping with expanded item 2) + var item3TopAfter = container.FindElement(By.CssSelector("[data-index='3']")).Location.Y; + Assert.True(item3TopAfter > item3TopBefore, + $"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); + js.ExecuteScript("arguments[0].scrollTop = 0", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) == 0); + + // Item 2 should still be expanded after scrolling + item2 = container.FindElement(By.CssSelector("[data-index='2']")); + Assert.Single(item2.FindElements(By.CssSelector(".expanded-content"))); + } + + [Fact] + public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + var status = Browser.Exists(By.Id("status")); + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll down so item 2 is not visible + js.ExecuteScript("arguments[0].scrollTop = 200", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 200); + + // Get the position of a visible item before expanding the off-screen item + var visibleItems = container.FindElements(By.CssSelector(".item")); + var firstVisibleItem = visibleItems.First(); + var firstVisibleTopBefore = firstVisibleItem.Location.Y; + var firstVisibleIndex = firstVisibleItem.GetDomAttribute("data-index"); + + // Expand item 2 (which should be above the visible area) + Browser.Exists(By.Id("expand-item-2")).Click(); + Browser.Equal("Item 2 expanded via button", () => status.Text); + + // Verify the visible item position didn't change + var sameItem = container.FindElement(By.CssSelector($"[data-index='{firstVisibleIndex}']")); + var firstVisibleTopAfter = sameItem.Location.Y; + + // The visible items should stay in place (or very close, allowing for minor reflow) + Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, + $"Visible item should not have moved when off-screen item expanded. Before: {firstVisibleTopBefore}, After: {firstVisibleTopAfter}"); + } + + [Fact] + public void VariableHeight_ContainerResizeWorks() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("variable-height-container")); + var resizeStatus = Browser.Exists(By.Id("resize-status")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".variable-height-item") > 0); + + Browser.Exists(By.Id("resize-large")).Click(); + Browser.Equal("Container resized to 400px", () => resizeStatus.Text); + var containerHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + Assert.Equal(400, containerHeight); + + // Scroll to end and verify last item + JumpToEndWithStabilization(container, hasPlaceholders: null, loadData: null); + Browser.True(() => container.FindElements(By.Id("variable-item-999")).Count > 0); + + Browser.Exists(By.Id("resize-small")).Click(); + Browser.Equal("Container resized to 100px", () => resizeStatus.Text); + containerHeight = (long)js.ExecuteScript("return arguments[0].clientHeight", container); + Assert.Equal(100, containerHeight); + + JumpToStartWithStabilization( + container, + hasPlaceholders: null, + loadData: null, + () => container.FindElements(By.Id("variable-item-0")).Count > 0); + Browser.True(() => container.FindElements(By.Id("variable-item-0")).Count > 0); + } + + [Fact] + public void VariableHeightAsync_LoadsItemsWithCorrectHeights() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("async-variable-container")); + var finishLoadingButton = Browser.Exists(By.Id("finish-loading")); + + Browser.Equal(0, () => GetElementCount(container, ".async-variable-item")); + Browser.Equal(0, () => GetElementCount(container, ".async-variable-placeholder")); + + finishLoadingButton.Click(); + Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); + + // Verify first item has correct variable height (25 + (0 * 11 % 31) = 25px) + var item0 = container.FindElement(By.Id("async-variable-item-0")); + Assert.Contains("height: 25px", item0.GetDomAttribute("style")); + + // Verify second item has different height (25 + (1 * 11 % 31) = 36px) + var item1 = container.FindElement(By.Id("async-variable-item-1")); + Assert.Contains("height: 36px", item1.GetDomAttribute("style")); + } + + [Fact] + public void VariableHeightAsync_RtlLayoutWorks() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("async-variable-container")); + var finishLoadingButton = Browser.Exists(By.Id("finish-loading")); + var js = (IJavaScriptExecutor)Browser; + + Browser.Exists(By.Id("toggle-rtl")).Click(); + Browser.Equal("Direction: RTL", () => Browser.Exists(By.Id("direction-status")).Text); + + finishLoadingButton.Click(); + Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); + JumpToEndWithStabilization( + container, + () => GetElementCount(container, ".async-variable-placeholder") > 0, + () => finishLoadingButton.Click()); + Browser.True(() => container.FindElements(By.Id("async-variable-item-999")).Count > 0); + JumpToStartWithStabilization( + container, + () => GetElementCount(container, ".async-variable-placeholder") > 0, + () => finishLoadingButton.Click(), + () => container.FindElements(By.Id("async-variable-item-0")).Count > 0); + Browser.True(() => container.FindElements(By.Id("async-variable-item-0")).Count > 0); + } + + [Fact] + public void VariableHeightAsync_CollectionMutationWorks() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("async-variable-container")); + var finishLoadingButton = Browser.Exists(By.Id("finish-loading")); + var addItemStartButton = Browser.Exists(By.Id("add-item-start")); + var removeItemMiddleButton = Browser.Exists(By.Id("remove-item-middle")); + var refreshButton = Browser.Exists(By.Id("refresh-data")); + var totalItemCount = Browser.Exists(By.Id("total-item-count")); + var js = (IJavaScriptExecutor)Browser; + + finishLoadingButton.Click(); + Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); + Browser.Equal("Total: 1000", () => totalItemCount.Text); + var firstItem = container.FindElement(By.Id("async-variable-item-0")); + Assert.Contains("height: 25px", firstItem.GetDomAttribute("style")); // 25 + 0*11%31 = 25px + + // Add item at START with distinctive 100px height + addItemStartButton.Click(); + Browser.Equal("Total: 1001", () => totalItemCount.Text); + + refreshButton.Click(); + finishLoadingButton.Click(); + + firstItem = container.FindElement(By.Id("async-variable-item-0")); + Assert.Contains("height: 100px", firstItem.GetDomAttribute("style")); + var secondItem = container.FindElement(By.Id("async-variable-item-1")); + Assert.Contains("height: 25px", secondItem.GetDomAttribute("style")); + + removeItemMiddleButton.Click(); + Browser.Equal("Total: 1000", () => totalItemCount.Text); + + refreshButton.Click(); + finishLoadingButton.Click(); + JumpToEndWithStabilization( + container, + () => GetElementCount(container, ".async-variable-placeholder") > 0, + () => finishLoadingButton.Click()); + Browser.True(() => container.FindElements(By.Id("async-variable-item-999")).Count > 0); + + JumpToStartWithStabilization( + container, + () => GetElementCount(container, ".async-variable-placeholder") > 0, + () => finishLoadingButton.Click(), + () => container.FindElements(By.Id("async-variable-item-0")).Count > 0); + + firstItem = container.FindElement(By.Id("async-variable-item-0")); + Assert.Contains("height: 100px", firstItem.GetDomAttribute("style")); + } + + [Fact] + public void VariableHeightAsync_SmallItemCountsWork() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("async-variable-container")); + var finishLoadingButton = Browser.Exists(By.Id("finish-loading")); + var setCount0Button = Browser.Exists(By.Id("set-count-0")); + var setCount1Button = Browser.Exists(By.Id("set-count-1")); + var setCount5Button = Browser.Exists(By.Id("set-count-5")); + var refreshButton = Browser.Exists(By.Id("refresh-data")); + var totalItemCount = Browser.Exists(By.Id("total-item-count")); + + finishLoadingButton.Click(); + Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); + Browser.Equal("Total: 1000", () => totalItemCount.Text); + + // Empty list (0 items) - should show EmptyContent + setCount0Button.Click(); + Browser.Equal("Total: 0", () => totalItemCount.Text); + refreshButton.Click(); + finishLoadingButton.Click(); + Browser.Equal(0, () => GetElementCount(container, ".async-variable-item")); + Browser.Exists(By.Id("no-data")); + + // Single item + setCount1Button.Click(); + Browser.Equal("Total: 1", () => totalItemCount.Text); + refreshButton.Click(); + finishLoadingButton.Click(); + Browser.Equal(1, () => GetElementCount(container, ".async-variable-item")); + Browser.DoesNotExist(By.Id("no-data")); + var singleItem = container.FindElement(By.Id("async-variable-item-0")); + Assert.Contains("height: 30px", singleItem.GetDomAttribute("style")); // 30 + 0*17%41 = 30px + + // 5 items + setCount5Button.Click(); + Browser.Equal("Total: 5", () => totalItemCount.Text); + refreshButton.Click(); + finishLoadingButton.Click(); + Browser.Equal(5, () => GetElementCount(container, ".async-variable-item")); + Browser.DoesNotExist(By.Id("no-data")); + + var item0 = container.FindElement(By.Id("async-variable-item-0")); + var item1 = container.FindElement(By.Id("async-variable-item-1")); + var item2 = container.FindElement(By.Id("async-variable-item-2")); + var item3 = container.FindElement(By.Id("async-variable-item-3")); + var item4 = container.FindElement(By.Id("async-variable-item-4")); + + Assert.Contains("height: 30px", item0.GetDomAttribute("style")); // 30 + 0*17%41 = 30 + Assert.Contains("height: 47px", item1.GetDomAttribute("style")); // 30 + 1*17%41 = 47 + Assert.Contains("height: 64px", item2.GetDomAttribute("style")); // 30 + 2*34%41 = 64 (34%41=34) + Assert.Contains("height: 40px", item3.GetDomAttribute("style")); // 30 + 3*51%41 = 30 + 10 = 40 + Assert.Contains("height: 57px", item4.GetDomAttribute("style")); // 30 + 4*68%41 = 30 + 27 = 57 + } + + [Theory] + [InlineData(100, 100, 100)] // baseline - no scaling + [InlineData(50, 100, 100)] // transform: scale(0.5) + [InlineData(100, 50, 100)] // CSS zoom: 0.5 + [InlineData(100, 100, 50)] // CSS scale: 0.5 + [InlineData(200, 100, 100)] // transform: scale(2) + [InlineData(100, 200, 100)] // CSS zoom: 2 + [InlineData(100, 100, 200)] // CSS scale: 2 + [InlineData(75, 75, 75)] // combined downscale: 0.75^3 ≈ 0.42x + [InlineData(150, 150, 150)] // combined upscale: 1.5^3 = 3.375x + public virtual void VariableHeightAsync_CanScrollWithoutFlashing(int transformScalePercent, int cssZoomPercent, int cssScalePercent) + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("async-variable-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.Exists(By.Id("toggle-autoload")).Click(); + + if (transformScalePercent != 100) + { + Browser.Exists(By.Id($"scale-{transformScalePercent}")).Click(); + } + if (cssZoomPercent != 100) + { + Browser.Exists(By.Id($"zoom-{cssZoomPercent}")).Click(); + } + if (cssScalePercent != 100) + { + Browser.Exists(By.Id($"cssscale-{cssScalePercent}")).Click(); + } + Browser.Equal($"Transform Scale: {transformScalePercent}%, CSS Zoom: {cssZoomPercent}%, CSS Scale: {cssScalePercent}%", + () => Browser.Exists(By.Id("zoom-status")).Text); + + var setCount200Button = Browser.Exists(By.Id("set-count-200")); + setCount200Button.Click(); + Browser.Exists(By.Id("refresh-data")).Click(); + + Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); + + const string detectFlashingScript = @" + var done = arguments[0]; + (async () => { + const SCROLL_INCREMENT = 100; + const MAX_ITERATIONS = 300; + const VISIBILITY_TOLERANCE = 2; + const container = document.querySelector('#async-variable-container'); + + if (!container) { + done({ success: false, error: 'Container not found' }); + return; + } + + const getTopVisibleItemIndex = () => { + const items = container.querySelectorAll('.async-variable-item'); + if (items.length === 0) return null; + const containerRect = container.getBoundingClientRect(); + for (const item of items) { + const itemRect = item.getBoundingClientRect(); + if (itemRect.bottom > containerRect.top + VISIBILITY_TOLERANCE && + itemRect.top < containerRect.bottom - VISIBILITY_TOLERANCE) { + const match = item.id.match(/async-variable-item-(\d+)/); + return match ? parseInt(match[1], 10) : null; + } + } + return null; + }; + + const getMaxIndex = () => { + const items = container.querySelectorAll('.async-variable-item'); + let maxIdx = -1; + for (const item of items) { + const match = item.id.match(/async-variable-item-(\d+)/); + if (match) maxIdx = Math.max(maxIdx, parseInt(match[1], 10)); + } + return maxIdx; + }; + + const getMinIndex = () => { + const items = container.querySelectorAll('.async-variable-item'); + let minIdx = Infinity; + for (const item of items) { + const match = item.id.match(/async-variable-item-(\d+)/); + if (match) minIdx = Math.min(minIdx, parseInt(match[1], 10)); + } + return minIdx === Infinity ? -1 : minIdx; + }; + + const waitForSettledFrame = () => { + return new Promise(resolve => { + requestAnimationFrame(() => { + const target = container.querySelector('.async-variable-item') || container; + const io = new IntersectionObserver(() => { + io.disconnect(); + resolve(); + }, { root: container, threshold: [0, 1] }); + io.observe(target); + }); + }); + }; + + const getSnapshot = () => { + const spacerBefore = container.querySelector('[aria-hidden=""true""]:first-child'); + const spacerAfter = container.querySelector('[aria-hidden=""true""]:last-child'); + return { + st: container.scrollTop, + sh: container.scrollHeight, + min: getMinIndex(), + max: getMaxIndex(), + cnt: container.querySelectorAll('.async-variable-item').length, + sbH: spacerBefore ? spacerBefore.style.height : '?', + saH: spacerAfter ? spacerAfter.style.height : '?', + }; + }; + + let previousTopItemIndex = null; + let maxIndexSeen = -1; + // Keep last N snapshots as ring buffer for context + const history = []; + const HISTORY_SIZE = 10; + + for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) { + const beforeScroll = getSnapshot(); + beforeScroll.phase = 'pre'; + beforeScroll.iter = iteration; + + const previousScrollTop = container.scrollTop; + container.scrollTop += SCROLL_INCREMENT; + + if (container.scrollTop === previousScrollTop) { + break; + } + + const afterAssign = { st: container.scrollTop, phase: 'post-assign', iter: iteration }; + + await waitForSettledFrame(); + + const afterSettle = getSnapshot(); + afterSettle.phase = 'settled'; + afterSettle.iter = iteration; + + const currentTopItemIndex = getTopVisibleItemIndex(); + afterSettle.topIdx = currentTopItemIndex; + + history.push({ beforeScroll, afterAssign, afterSettle }); + if (history.length > HISTORY_SIZE) history.shift(); + + if (previousTopItemIndex !== null && currentTopItemIndex !== null && currentTopItemIndex < previousTopItemIndex) { + // Format history as compact string + const histStr = history.map(h => { + const b = h.beforeScroll; + const a = h.afterSettle; + return `i${b.iter}:[st:${b.st}->${h.afterAssign.st}->${a.st}, items:${b.min}..${b.max}(${b.cnt})->${a.min}..${a.max}(${a.cnt}), sb:${b.sbH}->${a.sbH}, sa:${b.saH}->${a.saH}, top:${a.topIdx}]`; + }).join(' | '); + + const scale = container.offsetHeight === 0 ? 1 : + Math.round(container.getBoundingClientRect().height / container.offsetHeight * 1000) / 1000; + + done({ + success: false, + error: `Flashing at iter ${iteration}: ${previousTopItemIndex}->${currentTopItemIndex}. scale=${scale}, offsetH=${container.offsetHeight}. HISTORY: ${histStr}` + }); + return; + } + + if (currentTopItemIndex !== null) { + previousTopItemIndex = currentTopItemIndex; + } + maxIndexSeen = Math.max(maxIndexSeen, getMaxIndex()); + } + + done({ success: true, maxIndexSeen }); + })();"; + + var result = (Dictionary)js.ExecuteAsyncScript(detectFlashingScript); + var success = (bool)result["success"]; + if (!success) + { + Assert.Fail((string)result["error"]); + } + var maxIndexSeen = Convert.ToInt32(result["maxIndexSeen"], CultureInfo.InvariantCulture); + Assert.True(maxIndexSeen >= 199, $"Should have scrolled to the last item (saw up to index {maxIndexSeen})"); + } + + [Fact] + public void DisplayModes_BlockLayout_SupportsVariableHeights() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("block-container")); + var itemCount = Browser.Exists(By.Id("block-count")); + + Browser.Equal("50", () => itemCount.Text); + Browser.True(() => GetElementCount(container, ".block-item") > 0); + var firstItem = container.FindElement(By.Id("block-item-0")); + Assert.Contains("height: 30px", firstItem.GetDomAttribute("style")); // 30 + 0*17%51 = 30 + + var secondItem = container.FindElement(By.Id("block-item-1")); + Assert.Contains("height: 47px", secondItem.GetDomAttribute("style")); // 30 + 1*17%51 = 47 + + Browser.ExecuteJavaScript("document.getElementById('block-container').scrollTop = document.getElementById('block-container').scrollHeight;"); + Browser.True(() => GetElementCount(container, ".block-item") > 0); + } + + [Fact] + public void DisplayModes_GridLayout_SupportsVariableHeights() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("grid-container")); + var itemCount = Browser.Exists(By.Id("grid-count")); + + Browser.Equal("50", () => itemCount.Text); + Browser.True(() => GetElementCount(container, ".grid-item") > 0); + + var firstItem = container.FindElement(By.Id("grid-item-0")); + Assert.Contains("height: 30px", firstItem.GetDomAttribute("style")); + + Browser.ExecuteJavaScript("document.getElementById('grid-container').scrollTop = document.getElementById('grid-container').scrollHeight * 0.5;"); + Browser.True(() => GetElementCount(container, ".grid-item") > 0); + + Browser.ExecuteJavaScript("document.getElementById('grid-container').scrollTop = document.getElementById('grid-container').scrollHeight;"); + Browser.True(() => GetElementCount(container, ".grid-item") > 0); + } + + [Fact] + public void DisplayModes_SubgridLayout_SupportsVariableHeights() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("subgrid-container")); + var itemCount = Browser.Exists(By.Id("subgrid-count")); + + Browser.Equal("50", () => itemCount.Text); + Browser.True(() => GetElementCount(container, ".subgrid-item") > 0); + + var firstItem = container.FindElement(By.Id("subgrid-item-0")); + Assert.Contains("height: 30px", firstItem.GetDomAttribute("style")); + + Browser.ExecuteJavaScript("document.getElementById('subgrid-container').scrollTop = document.getElementById('subgrid-container').scrollHeight;"); + Browser.True(() => GetElementCount(container, ".subgrid-item") > 0); + } + + [Fact] + public void BimodalHeightDistribution_CanScrollThroughItems() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("bimodal-container")); + Browser.Equal("200", () => Browser.Exists(By.Id("bimodal-count")).Text); + Browser.True(() => GetElementCount(container, ".bimodal-item") > 0); + + var item0 = container.FindElement(By.Id("bimodal-item-0")); + Assert.Contains("height: 30px", item0.GetDomAttribute("style")); + var item1 = container.FindElement(By.Id("bimodal-item-1")); + Assert.Contains("height: 300px", item1.GetDomAttribute("style")); + + JumpToEndWithStabilization(container, hasPlaceholders: null, loadData: null); + Browser.True(() => container.FindElements(By.Id("bimodal-item-199")).Count > 0); + + JumpToStartWithStabilization( + container, + hasPlaceholders: null, + loadData: null, + () => container.FindElements(By.Id("bimodal-item-0")).Count > 0); + Browser.True(() => container.FindElements(By.Id("bimodal-item-0")).Count > 0); + } + + [Fact] + public void NestedVirtualize_BothRenderIndependently() + { + Browser.MountTestComponent(); + + var outerContainer = Browser.Exists(By.Id("nested-outer-container")); + Browser.Equal("50", () => Browser.Exists(By.Id("nested-outer-count")).Text); + Browser.Equal("100", () => Browser.Exists(By.Id("nested-inner-count")).Text); + + Browser.True(() => GetElementCount(outerContainer, ".nested-outer-item") > 0); + + var outerItemCount = GetElementCount(outerContainer, ".nested-outer-item"); + Assert.True(outerItemCount < 50, $"Outer rendered all {outerItemCount}/50 items — measurement may be polluted by inner item heights"); + + var innerContainer = Browser.Exists(By.Id("nested-inner-container")); + Browser.True(() => GetElementCount(innerContainer, ".nested-inner-item") > 0); + + var outerItem0 = outerContainer.FindElement(By.Id("nested-outer-item-0")); + Assert.Contains("height: 120px", outerItem0.GetDomAttribute("style")); + + var innerItem0 = innerContainer.FindElement(By.Id("nested-inner-item-0")); + Assert.Contains("height: 15px", innerItem0.GetDomAttribute("style")); + + Browser.ExecuteJavaScript("document.getElementById('nested-outer-container').scrollTop = 500;"); + Browser.True(() => GetElementCount(outerContainer, ".nested-outer-item") > 0); + + Browser.ExecuteJavaScript("document.getElementById('nested-outer-container').scrollTop = 0;"); + Browser.True(() => + { + try + { + var inner = Browser.FindElement(By.Id("nested-inner-container")); + return GetElementCount(inner, ".nested-inner-item") > 0; + } + catch + { + return false; + } + }); + } + + [Fact] + public void LargeDataset_CanScrollWithoutErrors() + { + Browser.MountTestComponent(); + + var loadButton = Browser.Exists(By.Id("load-large")); + loadButton.Click(); + Browser.Equal("Loaded 100000 items", () => Browser.Exists(By.Id("large-dataset-status")).Text); + + var container = Browser.Exists(By.Id("large-dataset-container")); + var js = (IJavaScriptExecutor)Browser; + + Browser.True(() => GetElementCount(container, ".large-dataset-item") > 0); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight * 0.01", container); + Browser.True(() => GetElementCount(container, ".large-dataset-item") > 0); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight * 0.5", container); + Browser.True(() => GetElementCount(container, ".large-dataset-item") > 0); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight * 0.99", container); + Browser.True(() => GetElementCount(container, ".large-dataset-item") > 0); + + JumpToEndWithStabilization(container, hasPlaceholders: null, loadData: null); + Browser.True(() => GetElementCount(container, ".large-dataset-item") > 0); + + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".large-dataset-item .item-index")); + return items.Any(item => + { + if (int.TryParse(item.Text, out var idx)) + { + return idx > 99900; + } + return false; + }); + }); + + JumpToStartWithStabilization( + container, + hasPlaceholders: null, + loadData: null, + () => container.FindElements(By.Id("large-item-0")).Count > 0); + Browser.True(() => container.FindElements(By.Id("large-item-0")).Count > 0); + } + + [Fact] + public void RapidScrollReversals_DoNotCauseErrors() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-behavior-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".scroll-behavior-item") > 0); + + const string rapidScrollScript = @" + var done = arguments[0]; + (async () => { + const container = document.getElementById('scroll-behavior-container'); + const wait = ms => new Promise(r => setTimeout(r, ms)); + + for (let i = 0; i < 20; i++) { + container.scrollTop += 300; + await wait(30); + container.scrollTop -= 150; + await wait(30); + } + await wait(200); + + const items = container.querySelectorAll('.scroll-behavior-item'); + done({ itemCount: items.length, scrollTop: container.scrollTop }); + })();"; + + var result = (Dictionary)js.ExecuteAsyncScript(rapidScrollScript); + var itemCount = Convert.ToInt32(result["itemCount"], CultureInfo.InvariantCulture); + Assert.True(itemCount > 0, "Items should still be visible after rapid scroll reversals"); + } + + [Fact] + public void ProgrammaticScrollToBottom_ReachesLastItems() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-behavior-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".scroll-behavior-item") > 0); + Browser.True(() => container.FindElements(By.Id("scroll-item-0")).Count > 0); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container); + WaitForScrollStabilization(container); + + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".scroll-behavior-item .item-index")); + return items.Any(item => + { + if (int.TryParse(item.Text, out var idx)) + { + return idx > 480; + } + return false; + }); + }); + } + + [Fact] + public void NonZeroStartIndex_ScrollToMiddleThenMeasure() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-behavior-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".scroll-behavior-item") > 0); + + js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight / 2", container); + WaitForScrollStabilization(container); + + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".scroll-behavior-item .item-index")); + return items.Any(item => + { + if (int.TryParse(item.Text, out var idx)) + { + return idx > 50; + } + return false; + }); + }); + + var visibleItems = container.FindElements(By.CssSelector(".scroll-behavior-item")); + Assert.True(visibleItems.Count > 0, "Should have visible items at non-zero start"); + + foreach (var item in visibleItems) + { + var style = item.GetDomAttribute("style"); + Assert.Contains("height:", style); + Assert.DoesNotContain("height: 0px", style); + } + } + + [Fact] + public void VariableHeight_VisualStability_NoBackwardJumps() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("async-variable-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.Exists(By.Id("toggle-autoload")).Click(); + + var setCount200Button = Browser.Exists(By.Id("set-count-200")); + setCount200Button.Click(); + Browser.Exists(By.Id("refresh-data")).Click(); + Browser.True(() => GetElementCount(container, ".async-variable-item") > 0); + + const string stabilityScript = @" + var done = arguments[0]; + (async () => { + const container = document.getElementById('async-variable-container'); + const scrollLog = []; + const wait = ms => new Promise(r => setTimeout(r, ms)); + + const scrollHandler = () => { + scrollLog.push({ + t: performance.now(), + st: container.scrollTop, + items: container.querySelectorAll('.async-variable-item').length + }); + }; + container.addEventListener('scroll', scrollHandler); + + for (let i = 0; i < 50; i++) { + container.scrollTop += 40; + await wait(50); + } + + container.removeEventListener('scroll', scrollHandler); + await wait(200); + + let backwardJumps = 0; + let maxBackwardJump = 0; + for (let i = 1; i < scrollLog.length; i++) { + const delta = scrollLog[i].st - scrollLog[i-1].st; + if (delta < -5) { + backwardJumps++; + maxBackwardJump = Math.max(maxBackwardJump, Math.abs(delta)); + } + } + + done({ + totalEvents: scrollLog.length, + backwardJumps: backwardJumps, + maxBackwardJump: maxBackwardJump, + finalScrollTop: container.scrollTop, + finalItemCount: container.querySelectorAll('.async-variable-item').length + }); + })();"; + + var result = (Dictionary)js.ExecuteAsyncScript(stabilityScript); + var backwardJumps = Convert.ToInt32(result["backwardJumps"], CultureInfo.InvariantCulture); + var totalEvents = Convert.ToInt32(result["totalEvents"], CultureInfo.InvariantCulture); + var finalItemCount = Convert.ToInt32(result["finalItemCount"], CultureInfo.InvariantCulture); + + Assert.True(finalItemCount > 0, "Should have items after scrolling"); + Assert.True(totalEvents > 0, "Should have recorded scroll events"); + Assert.True(backwardJumps <= 3, + $"Too many backward scroll jumps detected: {backwardJumps} out of {totalEvents} events " + + $"(max jump: {result["maxBackwardJump"]}px). This indicates visual instability."); + } + + [Fact] + public void ChatList_CanJumpToHomeFromEnd() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("async-chat-container")); + var finishLoadingButton = Browser.Exists(By.Id("async-chat-finish-loading")); + Func hasPlaceholders = () => GetElementCount(container, ".async-chat-placeholder") > 0; + Action loadData = () => finishLoadingButton.Click(); + + finishLoadingButton.Click(); + Browser.True(() => GetElementCount(container, ".async-chat-message") > 0); + + JumpToEndWithStabilization(container, hasPlaceholders, loadData); + Browser.True(() => container.FindElements(By.Id("async-chat-msg-499")).Count > 0); + + JumpToStartWithStabilization( + container, + hasPlaceholders, + loadData, + () => container.FindElements(By.Id("async-chat-msg-0")).Count > 0); + Browser.True(() => container.FindElements(By.Id("async-chat-msg-0")).Count > 0); + } + + [Fact] + public void QuickGrid_CanJumpToEndAndStart() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("grid-variable-height")); + var totalItems = Browser.Exists(By.Id("total-items")); + var providerCallCount = Browser.Exists(By.Id("items-provider-call-count")); + var dataLoaded = Browser.Exists(By.Id("data-loaded")); + + Browser.Equal("Total items: 1000", () => totalItems.Text); + Browser.Equal("Data loaded: True", () => dataLoaded.Text); + Browser.True(() => int.Parse(providerCallCount.Text.Replace("ItemsProvider calls: ", ""), CultureInfo.InvariantCulture) > 0); + + WaitForQuickGridDataRows(container); + Func isFirstRowId1 = () => CheckQuickGridFirstRow(container, text => text == "1"); + + JumpToStartWithStabilization( + container, + hasPlaceholders: null, // QuickGrid handles its own async loading + loadData: null, + isFirstItemVisible: isFirstRowId1); + Browser.True(isFirstRowId1); + + JumpToEndWithStabilization(container, hasPlaceholders: null, loadData: null); + WaitForQuickGridDataRows(container); + + Browser.True(() => CheckQuickGridFirstRow(container, text => int.TryParse(text, out var id) && id > 950)); + + JumpToStartWithStabilization( + container, + hasPlaceholders: null, + loadData: null, + isFirstItemVisible: isFirstRowId1); + Browser.True(isFirstRowId1); + } + + private void WaitForQuickGridDataRows(IWebElement container) + => Browser.True(() => CheckQuickGridFirstRow(container, text => int.TryParse(text, out _))); + + private static bool CheckQuickGridFirstRow(IWebElement container, Func predicate) + { + try + { + var rows = container.FindElements(By.CssSelector("tbody tr:not([aria-hidden])")); + if (rows.Count == 0) + { + return false; + } + var firstCell = rows[0].FindElements(By.CssSelector("td:not(.grid-cell-placeholder)")).FirstOrDefault(); + return firstCell != null && predicate(firstCell.Text); + } + catch (StaleElementReferenceException) + { + return false; + } + } + + [Fact] + public void NestedVariableHeight_OuterMeasurementsNotPollutedByInner() + { + Browser.MountTestComponent(); + + var outerContainer = Browser.Exists(By.Id("nested-vh-outer-container")); + Browser.Equal("Outer: 40", () => Browser.Exists(By.Id("nested-vh-outer-count")).Text); + + Browser.True(() => GetElementCount(outerContainer, ".nested-vh-outer") > 0); + + // Outer container is 400px with 100-300px items (~200px average). + // Should render a small subset of 40 total items. + // If outer measurement is polluted by inner items (15-45px), the average + // drops dramatically, causing it to think it can fit many more items. + var outerItemCount = GetElementCount(outerContainer, ".nested-vh-outer"); + Assert.True(outerItemCount < 40, + $"Outer rendered all {outerItemCount}/40 items — measurement likely polluted by inner item heights (15-45px vs 100-300px outer)"); + + Browser.True(() => GetElementCount(outerContainer, ".nested-vh-inner") > 0); + } + [Fact] public void VirtualizeWorksInsideHorizontalOverflowContainer() { diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 039896b183a3..aacac726930b 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -99,6 +99,7 @@ + @@ -127,7 +128,16 @@ + + + + + + + + + diff --git a/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVariableHeightComponent.razor b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVariableHeightComponent.razor new file mode 100644 index 000000000000..4e560cb090ae --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVariableHeightComponent.razor @@ -0,0 +1,77 @@ +@using Microsoft.AspNetCore.Components.QuickGrid +@using System.Linq + +

QuickGrid with Variable Height Rows

+ +

+ Test: Verify that QuickGrid works correctly with rows of varying heights + now that Virtualize supports variable-height items. +

+ +

ItemsProvider calls: @ItemsProviderCallCount

+

Total items: 1000

+

Data loaded: @_dataLoaded

+ +
+ + + + +
+ @context.Description +
+
+
+
+ +@code { + internal class VariableHeightItem + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + } + + private GridItemsProvider variableHeightProvider = default!; + private bool _dataLoaded; + + int ItemsProviderCallCount = 0; + + protected override void OnInitialized() + { + variableHeightProvider = async request => + { + await Task.Yield(); + ItemsProviderCallCount++; + + var items = Enumerable.Range(request.StartIndex, request.Count ?? 1000) + .Where(i => i < 1000) + .Select(i => new VariableHeightItem + { + Id = i + 1, + Name = $"Person {i + 1}", + Description = GenerateDescription(i) + }) + .ToList(); + + // Mark data as loaded when we get items starting from index 0 + if (request.StartIndex == 0 && items.Count > 0) + { + _dataLoaded = true; + } + + StateHasChanged(); + return GridItemsProviderResult.From(items: items, totalItemCount: 1000); + }; + } + + private string GenerateDescription(int index) + { + // Create varying content lengths to produce different row heights (1-5 lines) + var lineCount = 1 + (index % 5); + var lines = Enumerable.Range(0, lineCount) + .Select(l => $"Line {l + 1}: Item {index + 1} description text.") + .ToArray(); + return string.Join("\n", lines); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor index b875fad0eee3..3dcc215a0c0f 100644 --- a/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor @@ -26,7 +26,7 @@ itemsProvider = async request => { await Task.CompletedTask; - Interlocked.Increment(ref ItemsProviderCallCount); + ItemsProviderCallCount++; StateHasChanged(); return GridItemsProviderResult.From( items: Enumerable.Range(1, 100).Select(i => new Person { Id = i, Name = $"Person {i}" }).ToList(), diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationBimodal.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationBimodal.razor new file mode 100644 index 000000000000..c12234d930bf --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationBimodal.razor @@ -0,0 +1,105 @@ +@* Tests: bimodal height distribution, nested virtualize isolation, large dataset (100K items) *@ +

+ Bimodal Height Distribution (alternating 30px/300px items):
+

+ +
+ @context.Index - H:@context.Height +
+
+
+ @bimodalItems.Count +

+ +

+ Nested Virtualize (outer + inner, measurement isolation):
+

+ +
+ Outer @context.Index + @if (context.Index == 0) + { +
+ +
+ Inner @innerCtx.Index +
+
+
+ } +
+
+
+ @nestedOuterItems.Count + @nestedInnerItems.Count +

+ +

+ Large Dataset (100K items):
+ + @largeDatasetStatus + @if (largeItems != null) + { +

+ +
+ @context.Index +
+
+
+ } +

+ +@code { + ICollection bimodalItems; + ICollection nestedOuterItems; + ICollection nestedInnerItems; + ICollection largeItems; + string largeDatasetStatus = "Not loaded"; + + protected override void OnInitialized() + { + bimodalItems = Enumerable.Range(0, 200).Select(i => new HeightItem + { + Index = i, + Height = i % 2 == 0 ? 30 : 300, + Hue = i * 7 % 360 + }).ToList(); + + nestedOuterItems = Enumerable.Range(0, 50).Select(i => new HeightItem + { + Index = i, + Height = 120, + Hue = i * 30 % 360 + }).ToList(); + + nestedInnerItems = Enumerable.Range(0, 100).Select(i => new HeightItem + { + Index = i, + Height = 15 + (i * 7 % 21), + Hue = i * 12 % 360 + }).ToList(); + } + + void LoadLargeDataset() + { + largeItems = Enumerable.Range(0, 100_000).Select(i => new HeightItem + { + Index = i, + Height = 25 + (i * 13 % 41), + Hue = i * 3 % 360 + }).ToList(); + largeDatasetStatus = $"Loaded {largeItems.Count} items"; + } + + class HeightItem + { + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationChatListAsync.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationChatListAsync.razor new file mode 100644 index 000000000000..723ce1171a4f --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationChatListAsync.razor @@ -0,0 +1,85 @@ +@using Microsoft.AspNetCore.Components.Web.Virtualization + +

+ Chat List Async (newest at bottom):
+ +

+ + +
+ @context.Index +
+
+ +
+ Loading @context.Index... +
+
+
+
+

+ +@code { + List allItems = new(); + int nextId; + TaskCompletionSource loadingTcs = new TaskCompletionSource(); + + protected override void OnInitialized() + { + allItems = Enumerable.Range(0, 500).Select(i => new ChatMessage + { + Id = nextId++, + Index = i, + Height = 20 + (i * 37 % 1981), + Hue = (i * 47) % 360 + }).ToList(); + } + + async ValueTask> GetItemsAsync(ItemsProviderRequest request) + { + var registration = request.CancellationToken.Register(() => + { + loadingTcs.TrySetCanceled(request.CancellationToken); + loadingTcs = new TaskCompletionSource(); + InvokeAsync(StateHasChanged); + }); + + try + { + await loadingTcs.Task; + } + catch (OperationCanceledException) + { + registration.Dispose(); + throw; + } + + registration.Dispose(); + + var items = allItems + .Skip(request.StartIndex) + .Take(request.Count) + .ToList(); + + return new ItemsProviderResult(items, allItems.Count); + } + + void FinishLoading() + { + loadingTcs.TrySetResult(); + loadingTcs = new TaskCompletionSource(); + } + + class ChatMessage + { + public int Id { get; set; } + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationComponent.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationComponent.razor index 45c1afef3cde..88f60aea3e6c 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationComponent.razor @@ -6,7 +6,7 @@

Synchronous:

- +
Item @context
@@ -19,7 +19,7 @@
Cancellation count: @asyncCancellationCount
- +
Item @context
@@ -51,7 +51,7 @@

Slightly incorrect item size:

- +
Item @context
@@ -59,7 +59,7 @@

Viewport as root:
- +

Item @context

diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDisplayModes.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDisplayModes.razor new file mode 100644 index 000000000000..3867446946b2 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDisplayModes.razor @@ -0,0 +1,70 @@ +@* Test page for verifying Virtualize behavior with supported CSS display modes *@ + +

Virtualization with CSS Display Modes

+ +

+ Test: Verify that variable-height virtualization works correctly with supported CSS layout modes. +

+ +

1. display: block (default)

+

+

+ +
+ Block Item @context.Index (height: @context.Height px) +
+
+
+

+

Block items: @items.Count

+ +

2. display: grid; grid-template-columns: 1fr

+

+

+ +
+ Grid Item @context.Index (height: @context.Height px) +
+
+
+

+

Grid items: @items.Count

+ +

3. Subgrid (single-column parent)

+

+

+
+ +
+ @context.Index + Subgrid Item (height: @context.Height px) +
+
+
+
+

+

Subgrid items: @items.Count

+ +@code { + ICollection items = new List(); + + protected override void OnInitialized() + { + items = Enumerable.Range(0, 50).Select(i => new DisplayModeItem + { + Index = i, + Height = 30 + (i * 17 % 51), // Heights vary from 30-80px + Hue = (i * 7) % 360 + }).ToList(); + } + + class DisplayModeItem + { + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor new file mode 100644 index 000000000000..1c8a128d6f6a --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor @@ -0,0 +1,90 @@ +@using Microsoft.AspNetCore.Components.Web.Virtualization + +

Virtualization Dynamic Content

+

Tests for items that change height after initial render (images, expandables)

+ +
+ +
+
Item @item.Index
+ @if (item.IsExpanded) + { +
+ Expanded content for item @item.Index +
+ } +
+
+
+ +
+ + + + +
+ +

@statusMessage

+ +@code { + private List items = new(); + private string statusMessage = "Ready"; + + protected override void OnInitialized() + { + // Create 30 items with initial height of 50px + items = Enumerable.Range(0, 30) + .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) + .ToList(); + } + + private void ToggleExpand(DynamicItem item) + { + item.IsExpanded = !item.IsExpanded; + item.Height = item.IsExpanded ? 160 : 50; // 50 base + 100 expanded + padding + statusMessage = $"Item {item.Index} {(item.IsExpanded ? "expanded" : "collapsed")}"; + } + + private void ExpandItem(int index) + { + var item = items.FirstOrDefault(i => i.Index == index); + if (item != null && !item.IsExpanded) + { + item.IsExpanded = true; + item.Height = 160; + statusMessage = $"Item {index} expanded via button"; + } + } + + private void CollapseAll() + { + foreach (var item in items) + { + item.IsExpanded = false; + item.Height = 50; + } + statusMessage = "All items collapsed"; + } + + private void SimulateImageLoad() + { + // Simulate an image loading that increases item height + var item = items.FirstOrDefault(i => i.Index == 3); + if (item != null) + { + item.Height = 120; // Image loaded, item grew + statusMessage = "Item 3 height changed (simulated image load)"; + StateHasChanged(); + } + } + + private class DynamicItem + { + public int Index { get; set; } + public int Height { get; set; } + public bool IsExpanded { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor index 181e6eeec1bd..15e442e68a63 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount.razor @@ -11,7 +11,7 @@

- +
Id: @context.Id; Name: @context.Name
diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor index d272859d70b6..b7ff3ce19f57 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationMaxItemCount_AppContext.razor @@ -7,7 +7,7 @@
@* In .NET 8 and earlier, the E2E test uses an AppContext.SetData call to set MaxItemCount *@ @* In .NET 9 onwards, it's a Virtualize component parameter *@ - +
Id: @context.Id; Name: @context.Name
diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationNestedVariableHeight.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationNestedVariableHeight.razor new file mode 100644 index 000000000000..b4723439bea7 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationNestedVariableHeight.razor @@ -0,0 +1,57 @@ +

+ Nested Virtualize with variable heights at both levels:
+ Outer: @outerItems.Count + Inner per group: 30 +

+ +
+ Category @context.Index (H:@context.Height) +
+ +
+ Inner @innerCtx.Index (H:@innerCtx.Height) +
+
+
+
+
+
+

+ +@code { + List outerItems; + + protected override void OnInitialized() + { + outerItems = Enumerable.Range(0, 40).Select(i => new OuterItem + { + Index = i, + Height = 100 + (i * 47 % 201), // 100-300px + Hue = i * 30 % 360, + Children = Enumerable.Range(0, 30).Select(j => new InnerItem + { + Index = j, + Height = 15 + (j * 11 % 31), // 15-45px + Hue = j * 12 % 360 + }).ToList() + }).ToList(); + } + + class OuterItem + { + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + public List Children { get; set; } + } + + class InnerItem + { + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationScrollBehavior.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationScrollBehavior.razor new file mode 100644 index 000000000000..c022a402060e --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationScrollBehavior.razor @@ -0,0 +1,34 @@ +@* Tests: rapid scroll reversals, programmatic scrollToBottom, non-zero start index *@ +

+ Rapid Scroll & ScrollToBottom Test:
+ @items.Count +

+ +
+ @context.Index +
+
+
+

+ +@code { + ICollection items; + + protected override void OnInitialized() + { + items = Enumerable.Range(0, 500).Select(i => new HeightItem + { + Index = i, + Height = 25 + (i * 11 % 31), + Hue = i * 7 % 360 + }).ToList(); + } + + class HeightItem + { + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationTable.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationTable.razor index a928ec808945..2e74268615bf 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationTable.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationTable.razor @@ -9,7 +9,7 @@ - + Item @context Another value diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeight.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeight.razor new file mode 100644 index 000000000000..fa5fea9b915e --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeight.razor @@ -0,0 +1,60 @@ +

+ Test: Synchronous Items collection with extreme height variance (20-2000px). + Covers scrolling through all items, spacer adjustment, correct per-item heights, + and container resize while scrolled. +

+ +

+ Variable Height Items (small container):
+

+ +
+ @context.Index +
+
+
+

+ +

+ Total items: @variableHeightItems.Count +

+ +

+ Container height: @containerHeightpx + + + +

+ +

@resizeStatus

+ +@code { + ICollection variableHeightItems; + int containerHeight = 100; + string resizeStatus = "Ready"; + + void ResizeContainer(int newHeight) + { + containerHeight = newHeight; + resizeStatus = $"Container resized to {newHeight}px"; + } + + protected override void OnInitialized() + { + // Generate 1000 items with extreme height variance (20-2000px range = 100x variance) + variableHeightItems = Enumerable.Range(0, 1000).Select(i => new VariableHeightItem + { + Index = i, + Height = 20 + (i * 37 % 1981), // Heights vary from 20-2000px (100x extreme variance!) + Hue = i * 7 % 360 // Evenly distributed hues for visual distinction + }).ToList(); + } + + class VariableHeightItem + { + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeightAsync.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeightAsync.razor new file mode 100644 index 000000000000..4dd35a556aee --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeightAsync.razor @@ -0,0 +1,206 @@ +

+ Test: Async ItemsProvider with variable-height items (25-55px). + Covers manual/auto loading, RTL layout, transform scale, CSS zoom/scale, + collection mutations (add/remove), and small item counts (0, 1, 5). +

+ +

+ Variable Height Items with Async ItemsProvider:
+ + + + + + + + + + + + + + + + + + + + + + + + + + Direction: @(isRtl ? "RTL" : "LTR") + Transform Scale: @(transformScaleLevel * 100)%, CSS Zoom: @(cssZoomLevel * 100)%, CSS Scale: @(cssScaleLevel * 100)% + Loads: @loadCount + Cancellations: @cancellationCount + Total: @totalItemCount +

+ + +
+ @context.Index - Height: @context.Height px +
+
+ +
+ Loading @context.Index... +
+
+ +

No data to show

+
+
+
+

+ +@code { + Virtualize virtualizeRef; + TaskCompletionSource loadingTcs = new TaskCompletionSource(); + int totalItemCount = 1000; + int loadCount = 0; + int cancellationCount = 0; + bool autoLoad = false; + bool isRtl = false; + double transformScaleLevel = 1.0; + double cssZoomLevel = 1.0; + double cssScaleLevel = 1.0; + + // Pre-generate all items for consistency + List allItems; + int nextId = 0; + + void ToggleAutoLoad(ChangeEventArgs e) + { + autoLoad = (bool)e.Value; + } + + void ToggleRtl() + { + isRtl = !isRtl; + } + + void SetTransformScale(double scale) + { + transformScaleLevel = scale; + } + + void SetCssZoom(double zoom) + { + cssZoomLevel = zoom; + } + + void SetCssScale(double scale) + { + cssScaleLevel = scale; + } + + protected override void OnInitialized() + { + // Generate totalItemCount items (default 100) with deterministic variable heights (25-55px range) + allItems = Enumerable.Range(0, totalItemCount).Select(i => new VariableHeightItem + { + Id = nextId++, + Index = i, + Height = 25 + (i * 11 % 31), // Heights vary from 25-55px + Hue = i * 12 % 360 + }).ToList(); + } + + async ValueTask> GetItemsAsync(ItemsProviderRequest request) + { + if (!autoLoad) + { + var registration = request.CancellationToken.Register(() => + { + loadingTcs.TrySetCanceled(request.CancellationToken); + loadingTcs = new TaskCompletionSource(); + cancellationCount++; + InvokeAsync(StateHasChanged); + }); + + try + { + await loadingTcs.Task; + } + catch (OperationCanceledException) + { + registration.Dispose(); + throw; + } + + registration.Dispose(); + } + + loadCount++; + + var items = allItems + .Skip(request.StartIndex) + .Take(request.Count) + .ToList(); + + return new ItemsProviderResult(items, totalItemCount); + } + + void FinishLoading() + { + loadingTcs.TrySetResult(); + loadingTcs = new TaskCompletionSource(); + } + + void RecalculateIndices() + { + for (int i = 0; i < allItems.Count; i++) + { + allItems[i].Index = i; + } + } + + void AddItemAtStart() + { + var newItem = new VariableHeightItem + { + Id = nextId++, + Index = -1, + Height = 100, + Hue = 0 + }; + allItems.Insert(0, newItem); + totalItemCount++; + RecalculateIndices(); + } + + void RemoveItemFromMiddle() + { + if (totalItemCount > 2) + { + allItems.RemoveAt(totalItemCount / 2); + totalItemCount--; + RecalculateIndices(); + } + } + + void SetItemCount(int count) + { + // Generate new items with variable heights for small count scenarios + allItems = Enumerable.Range(0, count).Select(i => new VariableHeightItem + { + Id = nextId++, + Index = i, + Height = 30 + (i * 17 % 41), // Heights vary from 30-70px + Hue = i * 72 % 360 + }).ToList(); + totalItemCount = count; + } + + class VariableHeightItem + { + public int Id { get; set; } + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeightTable.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeightTable.razor new file mode 100644 index 000000000000..cf9391e77f0c --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationVariableHeightTable.razor @@ -0,0 +1,49 @@ +

This tests variable-height items inside an HTML table with SpacerElement="tr".

+ + + + + + + + + + + + + + + + +
ItemHeight
Item @context.Index +
+ @(context.Height)px +
+
+ +

Total items: @items.Count

+ + + +@code { + List items; + + protected override void OnInitialized() + { + items = Enumerable.Range(0, 500).Select(i => new VariableHeightRow + { + Index = i, + Height = 30 + (i * 37 % 171), + Hue = i * 7 % 360 + }).ToList(); + } + + class VariableHeightRow + { + public int Index { get; set; } + public int Height { get; set; } + public int Hue { get; set; } + } +}