diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index f968bedb3083..cc6ed8873a41 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -49,20 +49,28 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return; } - // Overflow anchoring can cause an ongoing scroll loop, because when we resize the spacers, the browser - // 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); const scrollElement = scrollContainer || document.documentElement; - scrollElement.style.overflowAnchor = 'none'; + const isTable = isValidTableElement(spacerAfter.parentElement); + const supportsAnchor = CSS.supports('overflow-anchor', 'auto'); + const useNativeAnchoring = !isTable && supportsAnchor; const rangeBetweenSpacers = document.createRange(); - if (isValidTableElement(spacerAfter.parentElement)) { + if (isTable) { spacerBefore.style.display = 'table-row'; spacerAfter.style.display = 'table-row'; } + if (useNativeAnchoring) { + // Prevent spacers from being used as scroll anchors — only rendered items should anchor. + spacerBefore.style.overflowAnchor = 'none'; + spacerAfter.style.overflowAnchor = 'none'; + } else { + // Manual compensation path for tables and browsers without native anchoring. + scrollElement.style.overflowAnchor = 'none'; + } + const intersectionObserver = new IntersectionObserver(intersectionCallback, { root: scrollContainer, rootMargin: `${rootMargin}px`, @@ -74,10 +82,48 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac let convergingElements = false; let convergenceItems: Set = new Set(); - // ResizeObserver roles: + const anchoredItems: Map = new Map(); + let scrollTriggeredRender = false; + + function getObservedHeight(entry: ResizeObserverEntry): number { + return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; + } + + function compensateScrollForItemResizes(entries: ResizeObserverEntry[]): void { + let scrollDelta = 0; + const containerTop = scrollContainer + ? scrollContainer.getBoundingClientRect().top + : 0; + + for (const entry of entries) { + if (entry.target === spacerBefore || entry.target === spacerAfter) { + continue; + } + + if (entry.target.isConnected) { + const el = entry.target as HTMLElement; + const oldHeight = anchoredItems.get(el); + const newHeight = getObservedHeight(entry); + anchoredItems.set(el, newHeight); + + if (oldHeight !== undefined && oldHeight !== newHeight) { + if (el.getBoundingClientRect().top < containerTop) { + scrollDelta += (newHeight - oldHeight); + } + } + } + } + + if (scrollDelta !== 0 && scrollElement.scrollTop > 0) { + scrollElement.scrollTop += scrollDelta; + } + } + + // 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. + // 3. Manual scroll compensation (tables/Safari) — adjusts scrollTop when above-viewport items resize. const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]): void => { for (const entry of entries) { if (entry.target === spacerBefore || entry.target === spacerAfter) { @@ -100,6 +146,11 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } else if (convergingElements) { stopConvergenceObserving(); } + + // Manual scroll compensation: adjust scrollTop for above-viewport resizes. + if (!useNativeAnchoring) { + compensateScrollForItemResizes(entries); + } }); // Always observe both spacers for the IntersectionObserver re-trigger. @@ -107,13 +158,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac 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)) { + // C# style updates overwrite the entire style attribute. Re-apply what we need. + if (isTable) { spacerBefore.style.display = 'table-row'; spacerAfter.style.display = 'table-row'; } + if (useNativeAnchoring) { + spacerBefore.style.overflowAnchor = 'none'; + spacerAfter.style.overflowAnchor = 'none'; + } + // Ensure spacers are always observed (idempotent). resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); @@ -132,8 +187,28 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } convergenceItems = currentItems; + return; } + // Manual compensation: observe items so ResizeObserver can compensate scrollTop. + // Skip for native anchoring (browser handles it) and scroll-triggered renders + // (avoids layout interference drift). + if (!useNativeAnchoring && !scrollTriggeredRender) { + const currentItems = new Set(); + for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { + resizeObserver.observe(el); + currentItems.add(el); + } + + for (const [el] of anchoredItems) { + if (!currentItems.has(el)) { + resizeObserver.unobserve(el); + anchoredItems.delete(el); + } + } + } + scrollTriggeredRender = false; + // Don't re-trigger IntersectionObserver here — ResizeObserver handles that // when spacers actually resize. Doing it on every render causes feedback loops. } @@ -141,6 +216,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac function startConvergenceObserving(): void { if (convergingElements) return; convergingElements = true; + if (useNativeAnchoring) { + scrollElement.style.overflowAnchor = 'none'; + } for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { resizeObserver.observe(el); convergenceItems.add(el); @@ -154,6 +232,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver.unobserve(el); } convergenceItems.clear(); + if (useNativeAnchoring) { + scrollElement.style.overflowAnchor = ''; + } + anchoredItems.clear(); } let convergingToBottom = false; @@ -168,9 +250,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (ke.key === 'End') { pendingJumpToEnd = true; pendingJumpToStart = false; + if (!convergingToBottom && spacerAfter.offsetHeight > 0) { + convergingToBottom = true; + startConvergenceObserving(); + } } else if (ke.key === 'Home') { pendingJumpToStart = true; pendingJumpToEnd = false; + if (!convergingToTop && spacerBefore.offsetHeight > 0) { + convergingToTop = true; + startConvergenceObserving(); + } } } keydownTarget.addEventListener('keydown', handleJumpKeys); @@ -185,8 +275,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac refreshObservedElements, scrollElement, startConvergenceObserving, + setConvergingToBottom: () => { convergingToBottom = true; }, onDispose: () => { stopConvergenceObserving(); + anchoredItems.clear(); resizeObserver.disconnect(); keydownTarget.removeEventListener('keydown', handleJumpKeys); if (callbackTimeout) { @@ -295,6 +387,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectingEntries.forEach((entry): void => { const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor; + // So that RefreshObservedElements can skip item observation (avoids layout interference drift). + scrollTriggeredRender = true; + if (entry.target === spacerBefore) { const spacerSize = (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor; dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize); @@ -322,6 +417,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; if (entry) { + entry.setConvergingToBottom?.(); entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; entry.startConvergenceObserving?.(); } diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 6622face0396..a90372571ca3 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -47,6 +47,9 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private IEnumerable? _loadedItems; + // For in-memory Items where objects have stable identity + private TItem? _previousFirstLoadedItem; + private CancellationTokenSource? _refreshCts; private bool _skipNextDistributionRefresh; @@ -515,9 +518,34 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess) // Only apply result if the task was not canceled. if (!cancellationToken.IsCancellationRequested) { + var previousItemCount = _itemCount; + var countDelta = result.TotalItemCount - previousItemCount; + + // Detect if items were prepended above the current viewport position. + if (countDelta > 0 && _itemsBefore > 0 && _previousFirstLoadedItem != null + && _itemsProvider == DefaultItemsProvider) + { + var newFirstItem = Items!.ElementAtOrDefault(_itemsBefore); + if (newFirstItem != null && !ReferenceEquals(_previousFirstLoadedItem, newFirstItem)) + { + _itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity)); + + var adjustedRequest = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken); + result = await _itemsProvider(adjustedRequest); + } + } + _itemCount = result.TotalItemCount; _loadedItems = result.Items; - _loadedItemsStartIndex = request.StartIndex; + _loadedItemsStartIndex = _itemsBefore; + + // Only needed for DefaultItemsProvider; custom providers return new instances + // per request, making ReferenceEquals unreliable. + _previousFirstLoadedItem = _itemsProvider == DefaultItemsProvider + && Items != null && _itemsBefore < Items.Count + ? Items.ElementAtOrDefault(_itemsBefore) + : default; + _loading = false; _skipNextDistributionRefresh = request.Count > 0; diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index fa40f512f31c..41c2dad63270 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -39,7 +39,7 @@ public void AlwaysFillsVisibleCapacity_Sync() { Browser.MountTestComponent(); var topSpacer = Browser.Exists(By.Id("sync-container")).FindElement(By.TagName("div")); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; int initialItemCount = 0; @@ -202,7 +202,7 @@ public void CancelsOutdatedRefreshes_Async() public void CanUseViewportAsContainer() { Browser.MountTestComponent(); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; var topSpacer = Browser.Exists(By.Id("viewport-as-root")).FindElement(By.TagName("div")); Browser.ExecuteJavaScript("const element = document.getElementById('viewport-as-root'); element.scrollIntoView();"); @@ -226,7 +226,7 @@ public async Task ToleratesIncorrectItemSize() { Browser.MountTestComponent(); var topSpacer = Browser.Exists(By.Id("incorrect-size-container")).FindElement(By.TagName("div")); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; // Wait until items have been rendered. Browser.True(() => GetItemCount() > 0); @@ -777,14 +777,18 @@ private void JumpToEndWithStabilization( 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," + + " spacerBeforeHeight: spacers.length >= 1 ? spacers[0].offsetHeight : -1," + + " overflowAnchor: getComputedStyle(c).overflowAnchor || 'N/A'," + " scrollTop: c.scrollTop, scrollHeight: c.scrollHeight, clientHeight: c.clientHeight };", container); var spacerAfterHeight = Convert.ToDouble(metrics["spacerAfterHeight"], CultureInfo.InvariantCulture); + var spacerBeforeHeight = Convert.ToDouble(metrics["spacerBeforeHeight"], CultureInfo.InvariantCulture); + var overflowAnchor = metrics["overflowAnchor"]?.ToString() ?? "N/A"; 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}"); + endDiagnostics.AppendLine(CultureInfo.InvariantCulture, $" poll #{endPollCount}: spacerAfter={spacerAfterHeight:F1}, spacerBefore={spacerBeforeHeight:F1}, scrollTop={scrollTop:F1}, scrollHeight={scrollHeight:F1}, clientHeight={clientHeight:F1}, remaining={remaining:F1}, overflowAnchor={overflowAnchor}"); return spacerAfterHeight < 1; }); @@ -1029,6 +1033,116 @@ public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() $"Visible item should not have moved when off-screen item expanded. Before: {firstVisibleTopBefore}, After: {firstVisibleTopAfter}"); } + [Fact] + public void DynamicContent_PrependItemsWhileScrolledToMiddle_VisibleItemsStayInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll past the overscan window to force true virtualization. + js.ExecuteScript("arguments[0].scrollTop = 5000", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 5000); + + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".item")); + if (items.Count == 0) + { + return false; + } + + var idx = items.First().GetDomAttribute("data-index"); + return idx != null && int.Parse(idx, CultureInfo.InvariantCulture) > 10; + }); + + // Record the first visible item and its Y position. + var containerRect = container.Location; + var containerHeight = container.Size.Height; + var visibleItems = container.FindElements(By.CssSelector(".item")); + string firstVisibleIndex = null; + int firstVisibleTopBefore = 0; + foreach (var item in visibleItems) + { + var relativeY = item.Location.Y - containerRect.Y; + if (relativeY >= 0 && relativeY < containerHeight) + { + firstVisibleIndex = item.GetDomAttribute("data-index"); + firstVisibleTopBefore = item.Location.Y; + break; + } + } + Assert.NotNull(firstVisibleIndex); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // The visible item should stay in place after prepend. + var sameItem = container.FindElement(By.CssSelector($"[data-index='{firstVisibleIndex}']")); + var firstVisibleTopAfter = sameItem.Location.Y; + + Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, + $"Visible item {firstVisibleIndex} should not move when items are prepended above. " + + $"Before Y: {firstVisibleTopBefore}, After Y: {firstVisibleTopAfter}"); + + Browser.True(() => container.FindElements(By.CssSelector(".item")).Count > 0); + + // Verify prepended items are reachable at the top. + js.ExecuteScript("arguments[0].scrollTop = 0", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) == 0); + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-10']")).Count > 0); + var topItems = container.FindElements(By.CssSelector(".item")); + Assert.True(topItems.Count > 0, "Should render items after scrolling back to top."); + } + + [Fact] + public void DynamicContent_AppendItemsWhileScrolledToMiddle_VisibleItemsStayInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll past the overscan window to force true virtualization. + js.ExecuteScript("arguments[0].scrollTop = 5000", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 5000); + + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".item")); + if (items.Count == 0) + { + return false; + } + + var idx = items.First().GetDomAttribute("data-index"); + return idx != null && int.Parse(idx, CultureInfo.InvariantCulture) > 10; + }); + var visibleItems = container.FindElements(By.CssSelector(".item")); + var firstVisible = visibleItems.First(); + var firstVisibleIndex = firstVisible.GetDomAttribute("data-index"); + var firstVisibleTopBefore = firstVisible.Location.Y; + var scrollTopBefore = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Appending below the viewport should not affect visible items. + var sameItem = container.FindElement(By.CssSelector($"[data-index='{firstVisibleIndex}']")); + var firstVisibleTopAfter = sameItem.Location.Y; + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, + $"Visible item {firstVisibleIndex} should not move when items are appended below. " + + $"Before: {firstVisibleTopBefore}, After: {firstVisibleTopAfter}"); + Assert.True(Math.Abs(scrollTopAfter - scrollTopBefore) < 5, + $"scrollTop should not change when appending below viewport. " + + $"Before: {scrollTopBefore}, After: {scrollTopAfter}"); + } + [Fact] public void VariableHeight_ContainerResizeWorks() { @@ -1662,6 +1776,132 @@ public void VirtualizeWorksInsideHorizontalOverflowContainer() Browser.True(() => lastElement.Displayed); } + [Fact] + public void ViewportAnchoring_ExpandAboveViewport_VisibleItemStaysInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll down so item 5 is above the viewport but still in DOM and verify its position + js.ExecuteScript("arguments[0].scrollTop = 500", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 500); + Browser.True(() => container.FindElements(By.CssSelector("[data-index='5']")).Count > 0); + + // Record the first visible item and its position relative to the container. + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + var scrollTopBefore = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("expand-item-5")).Click(); + Browser.Contains("Item 5 expanded via button", () => Browser.Exists(By.Id("status")).Text); + + // Wait for the expanded content for item 5 to appear in the DOM + Browser.True(() => container.FindElements(By.CssSelector("[data-index='5'] .expanded-content")).Count > 0); + + var (_, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item", indexBefore); + + Assert.True(Math.Abs(relTopAfter - relTopBefore) < 5, + $"Visible item '{indexBefore}' should not have moved when off-screen item expanded. " + + $"RelTop Before: {relTopBefore:F1}, After: {relTopAfter:F1}, Delta: {relTopAfter - relTopBefore:F1}px. " + + $"scrollTop: {scrollTopBefore}->{scrollTopAfter}."); + } + + [Fact] + public void ViewportAnchoring_CollapseAboveViewport_VisibleItemStaysInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Expand item 5 while it's visible (at the top) + Browser.Exists(By.Id("expand-item-5")).Click(); + Browser.Contains("Item 5 expanded via button", () => Browser.Exists(By.Id("status")).Text); + + // Scroll down past item 5 so it's in overscan above viewport + js.ExecuteScript("arguments[0].scrollTop = 600", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 600); + + // Record first visible item position relative to container + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("collapse-all")).Click(); + Browser.Contains("All items collapsed", () => Browser.Exists(By.Id("status")).Text); + + var (_, relTopAfter, _) = GetItemPositionInContainer(js, container, ".item", indexBefore); + + Assert.True(Math.Abs(relTopAfter - relTopBefore) < 5, + $"Visible item '{indexBefore}' should not have moved when off-screen item collapsed. " + + $"RelTop Before: {relTopBefore:F1}, After: {relTopAfter:F1}, Delta: {relTopAfter - relTopBefore:F1}px"); + } + + [Fact] + public void Table_VariableHeight_IncrementalScrollDoesNotCauseWildJumps() + { + // Guards against a browser-level CSS Scroll Anchoring bug with layout. + // When overflow-anchor is allowed on elements, the anchoring algorithm + // miscalculates row positions causing 3000-8000px jumps per 300px scroll. + // The fix disables browser anchoring for tables and uses manual JS compensation. + Browser.MountTestComponent(); + + var js = (IJavaScriptExecutor)Browser; + Browser.Equal("Total items: 500", () => Browser.Exists(By.Id("vht-total-items")).Text); + Browser.True(() => Browser.Exists(By.Id("vht-row-0")).Displayed); + + // Scroll to initial offset and wait deterministically for re-render + js.ExecuteScript("window.scrollTo(0, 1000)"); + Browser.True(() => Browser.FindElements(By.Id("vht-row-10")).Count > 0); + + var result = ExecuteViewportScrollJumpDetectionScript("variable-height-table"); + + var maxJump = Convert.ToInt64(result["maxJump"], CultureInfo.InvariantCulture); + var totalDelta = Convert.ToInt64(result["totalDelta"], CultureInfo.InvariantCulture); + var expectedDelta = Convert.ToInt64(result["expected"], CultureInfo.InvariantCulture); + + // Without the fix: individual jumps of 3000-8000px, total drift 30,000+. + // With the fix: ~300px per step, total ~4500px. + Assert.True(maxJump < 1500, + $"Table scroll should not produce wild jumps. " + + $"Max single jump was {maxJump}px (expected ~300px each). " + + $"Total: {result["startY"]}->{result["endY"]} = {totalDelta}px (expected ~{expectedDelta}px). " + + $"Jumps: [{result["jumps"]}]. " + + $"Large jumps indicate CSS scroll anchoring is miscalculating on elements."); + } + + private static (string index, double relTop, long scrollTop) GetItemPositionInContainer( + IJavaScriptExecutor js, IWebElement container, string itemSelector, string dataIndex = null) + { + var result = js.ExecuteScript(@" + var container = arguments[0]; + var selector = arguments[1]; + var targetIndex = arguments[2]; + var containerRect = container.getBoundingClientRect(); + var items = container.querySelectorAll(selector); + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var itemRect = item.getBoundingClientRect(); + if (targetIndex != null) { + if (item.getAttribute('data-index') === targetIndex) { + return { index: targetIndex, relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; + } + } else if (itemRect.top >= containerRect.top - 1 && itemRect.top < containerRect.bottom) { + return { index: item.getAttribute('data-index'), relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; + } + } + return null; + ", container, itemSelector, dataIndex) as Dictionary; + + Assert.NotNull(result); + return ( + result["index"].ToString(), + Convert.ToDouble(result["relTop"], CultureInfo.InvariantCulture), + Convert.ToInt64(result["scrollTop"], CultureInfo.InvariantCulture)); + } + /// /// Scrolls through all items detecting visual flashing (backward index jumps). /// Scrolls in 100px increments up to 300 iterations, tracking the top visible item index. @@ -1885,4 +2125,77 @@ private Dictionary ExecuteScrollStabilityScript(string container return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); } + + /// + /// Performs incremental viewport-level scrolls (window.scrollBy) on a table element, + /// using MutationObserver + requestAnimationFrame to deterministically wait for each + /// render cycle to settle. Measures per-step scroll jumps to detect CSS Scroll Anchoring + /// miscalculations on <tr> elements. + /// Returns startY, endY, totalDelta, expected, maxJump, and comma-separated jumps. + /// + private Dictionary ExecuteViewportScrollJumpDetectionScript( + string tableId, int scrollCount = 15, int scrollDelta = 300) + { + var script = $@" + var done = arguments[0]; + (async () => {{ + const tbody = document.querySelector('#{tableId} > tbody'); + + // Event-driven wait: watches for DOM mutations from C# re-renders, + // then waits 3 animation frames with no new mutations (layout settled). + // Falls back after 30 frames if no mutations occur. + const waitForRenderSettle = () => new Promise(resolve => {{ + let mutationSeen = false; + let framesSinceLastMutation = 0; + let totalFrames = 0; + + const mo = new MutationObserver(() => {{ + mutationSeen = true; + framesSinceLastMutation = 0; + }}); + mo.observe(tbody, {{ childList: true, subtree: true }}); + + const checkSettle = () => {{ + totalFrames++; + framesSinceLastMutation++; + if ((mutationSeen && framesSinceLastMutation >= 3) || totalFrames >= 30) {{ + mo.disconnect(); + resolve(); + }} else {{ + requestAnimationFrame(checkSettle); + }} + }}; + requestAnimationFrame(checkSettle); + }}); + + const scrollCount = {scrollCount}; + const scrollDelta = {scrollDelta}; + const startY = Math.round(window.scrollY); + let maxJump = 0; + let prevY = startY; + const jumps = []; + + for (let i = 0; i < scrollCount; i++) {{ + window.scrollBy(0, scrollDelta); + await waitForRenderSettle(); + + const currentY = Math.round(window.scrollY); + const jump = currentY - prevY; + jumps.push(jump); + if (Math.abs(jump) > Math.abs(maxJump)) maxJump = jump; + prevY = currentY; + }} + + done({{ + startY: startY, + endY: Math.round(window.scrollY), + totalDelta: Math.round(window.scrollY) - startY, + expected: scrollCount * scrollDelta, + maxJump: maxJump, + jumps: jumps.join(',') + }}); + }})();"; + + return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); + } } diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor index 7188b11abcc9..5230a0f0c8e3 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor @@ -25,6 +25,8 @@ + +

@statusMessage

@@ -35,8 +37,10 @@ protected override void OnInitialized() { - // 75 items with initial height of 50px (enough for true off-screen virtualization) - items = Enumerable.Range(0, 75) + // 500 items with initial height of 50px. + // With OverscanCount=15 (default), the initial render covers ~37 items (~2300px). + // 500 items provides enough content to force true virtualization when scrolling. + items = Enumerable.Range(0, 500) .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) .ToList(); } @@ -81,6 +85,30 @@ } } + private int nextPrependIndex = -1; + private int nextAppendIndex = 500; + + private void PrependItems() + { + var newItems = Enumerable.Range(0, 10) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = 50, IsExpanded = false }) + .ToList(); + items.InsertRange(0, newItems); + nextPrependIndex -= 10; + statusMessage = $"Prepended 10 items (indices {newItems.Last().Index}..{newItems.First().Index})"; + } + + private void AppendItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 10) + .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) + .ToList(); + items.AddRange(newItems); + nextAppendIndex += 10; + statusMessage = $"Appended 10 items (indices {startIndex}..{startIndex + 9})"; + } + private class DynamicItem { public int Index { get; set; }