diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 81dbce67a508..5e0bbb0e047e 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -97,13 +97,49 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height; const containerSize = entry.rootBounds?.height; + // Accumulate scale factors from all parent elements as they multiply together + let scaleFactor = 1.0; + + // Check for CSS scale/zoom/transform properties on parent elements (including body and html) + let element = spacerBefore.parentElement; + while (element) { + const computedStyle = getComputedStyle(element); + + // Check for zoom property (applies uniform scaling) + if (computedStyle.zoom && computedStyle.zoom !== '1') { + scaleFactor *= parseFloat(computedStyle.zoom); + } + + // Check for scale property (can have separate X/Y values) + if (computedStyle.scale && computedStyle.scale !== 'none' && computedStyle.scale !== '1') { + const parts = computedStyle.scale.split(' '); + const scaleY = parts.length > 1 ? parseFloat(parts[1]) : parseFloat(parts[0]); + scaleFactor *= scaleY; // Use vertical scale for vertical scrolling + } + + // Check for transform property (matrix form) + if (computedStyle.transform && computedStyle.transform !== 'none') { + const matrix = new DOMMatrix(computedStyle.transform); + scaleFactor *= matrix.d; + } + element = element.parentElement; + } + + // Divide by scale factor to convert from physical pixels to logical pixels. + const unscaledSpacerSeparation = spacerSeparation / scaleFactor; + const unscaledContainerSize = containerSize !== null && containerSize !== undefined ? containerSize / scaleFactor : null; + if (entry.target === spacerBefore) { - dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', entry.intersectionRect.top - entry.boundingClientRect.top, spacerSeparation, containerSize); + const spacerSize = entry.intersectionRect.top - entry.boundingClientRect.top; + const unscaledSpacerSize = spacerSize / scaleFactor; + dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', unscaledSpacerSize, unscaledSpacerSeparation, unscaledContainerSize); } 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; + const unscaledSpacerSize = spacerSize / scaleFactor; + dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', unscaledSpacerSize, unscaledSpacerSeparation, unscaledContainerSize); } }); } diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 4fa21ef3fe11..d3fd5c3ca8d8 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Linq; using BasicTestApp; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; @@ -426,6 +427,81 @@ public void CanRefreshItemsProviderResultsInPlace() name => Assert.Equal("Person 3", name)); } + [Theory] + [InlineData("1")] + [InlineData("2")] + [InlineData("0.5")] + public void CanScrollWhenAppliedScale(string scaleY) + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("virtualize")); + + Browser.True(() => + { + try + { + return Browser.FindElement(By.Id("virtualize")) + .FindElements(By.CssSelector("tbody tr.person")).Count > 0; + } + catch (StaleElementReferenceException) + { + return false; + } + }); + + Browser.FindElement(By.Id("scale-y-input")).Clear(); + Browser.FindElement(By.Id("scale-y-input")).SendKeys(scaleY); + Browser.FindElement(By.Id("btn")).Click(); + + // Scroll slowly (10px increments) and check if the first visible item changes unexpectedly. + // If the item index goes backward (e.g., from "5" to "4") instead of + // staying the same or advancing, that indicates flashing. + const string detectFlashingScript = @" + var done = arguments[0]; + (async () => { + const SCROLL_INCREMENT = 10; + let previousTopItemIndex = null; + const container = document.querySelector('#virtualize'); + if (!container) { + done(false); + return; + } + + const getTopVisibleItemIndex = () => { + const firstRow = container.querySelector('tbody tr.person span'); + if (!firstRow) return null; + return parseInt(firstRow.textContent, 10); + }; + + while (true) { + const previousScrollTop = container.scrollTop; + container.scrollTop += SCROLL_INCREMENT; + await new Promise(resolve => requestAnimationFrame(resolve)); + const currentScrollTop = container.scrollTop; + if (currentScrollTop === previousScrollTop) { + done(true); + return; + } + const currentTopItemIndex = getTopVisibleItemIndex(); + if (currentTopItemIndex === null) { + done(false); + return; + } + if (previousTopItemIndex !== null) { + if (currentTopItemIndex < previousTopItemIndex) { + done(false); + return; + } + } + previousTopItemIndex = currentTopItemIndex; + } + })();"; + + var jsExecutor = (IJavaScriptExecutor)Browser; + var noFlashingDetected = (bool)jsExecutor.ExecuteAsyncScript(detectFlashingScript); + Assert.True(noFlashingDetected); + } + [Theory] [InlineData("simple-scroll-horizontal")] [InlineData("complex-scroll-horizontal")] diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index f9cc718f7ca1..3f5b57ed57cc 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -123,6 +123,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor index b875fad0eee3..f7b6e894a506 100644 --- a/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridVirtualizeComponent.razor @@ -29,7 +29,7 @@ Interlocked.Increment(ref ItemsProviderCallCount); StateHasChanged(); return GridItemsProviderResult.From( - items: Enumerable.Range(1, 100).Select(i => new Person { Id = i, Name = $"Person {i}" }).ToList(), + items: Enumerable.Range(request.StartIndex, request.Count ?? 10).Select(i => new Person { Id = i, Name = $"Person {i}" }).ToList(), totalItemCount: 100); }; } diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor new file mode 100644 index 000000000000..baa639ab3ec8 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor @@ -0,0 +1,39 @@ +
+
+ + + + @context.Id + + +
+
+
+ + + + + +@code { + internal class Person + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public double? ScaleX { get; set; } = 1.0; + public double? ScaleY { get; set; } = 1.0; + + private ItemsProviderDelegate itemsProvider = default!; + + protected override void OnInitialized() + { + itemsProvider = async request => + { + await Task.CompletedTask; + return new ItemsProviderResult( + items: Enumerable.Range(request.StartIndex, request.Count).Select(i => new Person { Id = i, Name = $"Person {i}" }).ToList(), + totalItemCount: 100); + }; + } +}