From 68d743a459b38aa098137009a11153e35c5e8081 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Tue, 7 Oct 2025 18:05:12 +0200 Subject: [PATCH 1/4] Fix the bug with the scale calculation in Virtualize component --- src/Components/Web.JS/src/Virtualize.ts | 50 +++++++++++++++++-- .../test/E2ETest/Tests/VirtualizationTest.cs | 12 +++++ .../test/testassets/BasicTestApp/Index.razor | 1 + .../QuickGridVirtualizeComponent.razor | 2 +- .../BasicTestApp/VirtualizationScale.razor | 33 ++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 81dbce67a508..3366e57fcfad 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -94,16 +94,60 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // scrolling glitches. rangeBetweenSpacers.setStartAfter(spacerBefore); rangeBetweenSpacers.setEndBefore(spacerAfter); - const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height; + const rangeRect = rangeBetweenSpacers.getBoundingClientRect(); + const spacerSeparation = rangeRect.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 !== 'normal' && 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 scaleX = parseFloat(parts[0]); + const scaleY = parts.length > 1 ? parseFloat(parts[1]) : scaleX; + scaleFactor *= scaleY; // Use vertical scale for vertical scrolling + } + + // Check for transform property (matrix form) + if (computedStyle.transform && computedStyle.transform !== 'none') { + const match = computedStyle.transform.match(/matrix\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+)/); + if (match) { + const scaleX = parseFloat(match[1]); + const scaleY = parseFloat(match[4]); + if (Math.abs(scaleX - 1.0) > 0.01 || Math.abs(scaleY - 1.0) > 0.01) { + scaleFactor *= scaleY; // Use vertical scale + } + } + } + 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..bb41229abc2a 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -426,6 +426,18 @@ public void CanRefreshItemsProviderResultsInPlace() name => Assert.Equal("Person 3", name)); } + [Fact] + public void CanScrollWhenAppliedScale() + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("virtualize")); + + var people = GetPeopleNames(container); + Assert.True(GetPeopleNames(container).Length > 0); + ScrollTopToEnd(Browser, container); + Assert.True(GetPeopleNames(container).Length > 0); + } + [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..935646f76ea9 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor @@ -0,0 +1,33 @@ +
+ + @context.Name + +
+ +@code { + internal class Person + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + 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); + }; + } +} + + From 28757858663b06af13c3d2af15e7cf90486f8372 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Mon, 13 Oct 2025 17:11:53 +0200 Subject: [PATCH 2/4] Feedback --- src/Components/Web.JS/src/Virtualize.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 3366e57fcfad..5e21c1729490 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -121,13 +121,11 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // Check for transform property (matrix form) if (computedStyle.transform && computedStyle.transform !== 'none') { - const match = computedStyle.transform.match(/matrix\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+)/); + // A 2D transform matrix has 6 values: matrix(scaleX, skewY, skewX, scaleY, translateX, translateY) + const match = computedStyle.transform.match(/matrix\([^,]+,\s*[^,]+,\s*[^,]+,\s*([^,]+)/); if (match) { - const scaleX = parseFloat(match[1]); - const scaleY = parseFloat(match[4]); - if (Math.abs(scaleX - 1.0) > 0.01 || Math.abs(scaleY - 1.0) > 0.01) { - scaleFactor *= scaleY; // Use vertical scale - } + const scaleY = parseFloat(match[1]); + scaleFactor *= scaleY; } } element = element.parentElement; From 7561f2571984eb5f09402e251796f76a44579748 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Tue, 14 Oct 2025 13:17:59 +0200 Subject: [PATCH 3/4] Feedback --- src/Components/Web.JS/src/Virtualize.ts | 15 +++++------- .../BasicTestApp/VirtualizationScale.razor | 23 ++++++++++--------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 5e21c1729490..9d4d096d16d8 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -94,8 +94,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // scrolling glitches. rangeBetweenSpacers.setStartAfter(spacerBefore); rangeBetweenSpacers.setEndBefore(spacerAfter); - const rangeRect = rangeBetweenSpacers.getBoundingClientRect(); - const spacerSeparation = rangeRect.height; + const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height; const containerSize = entry.rootBounds?.height; // Accumulate scale factors from all parent elements as they multiply together @@ -107,7 +106,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const computedStyle = getComputedStyle(element); // Check for zoom property (applies uniform scaling) - if (computedStyle.zoom && computedStyle.zoom !== 'normal' && computedStyle.zoom !== '1') { + if (computedStyle.zoom && computedStyle.zoom !== '1') { scaleFactor *= parseFloat(computedStyle.zoom); } @@ -121,12 +120,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // Check for transform property (matrix form) if (computedStyle.transform && computedStyle.transform !== 'none') { - // A 2D transform matrix has 6 values: matrix(scaleX, skewY, skewX, scaleY, translateX, translateY) - const match = computedStyle.transform.match(/matrix\([^,]+,\s*[^,]+,\s*[^,]+,\s*([^,]+)/); - if (match) { - const scaleY = parseFloat(match[1]); - scaleFactor *= scaleY; - } + // Parse the transform matrix using DOMMatrix to extract scaleY + const matrix = new DOMMatrix(computedStyle.transform); + // For vertical scrolling, we only need the Y-axis scale factor (d/m22) + scaleFactor *= matrix.d; } element = element.parentElement; } diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor index 935646f76ea9..ddbd4459c1ed 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor @@ -1,9 +1,14 @@ -
- - @context.Name - +
+
+ + @context.Name + +
+ + + @code { internal class Person { @@ -11,6 +16,9 @@ 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() @@ -24,10 +32,3 @@ }; } } - - From b3b3f28b9e3fe353973aec09a4fe5ae0f64efe8c Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Thu, 16 Oct 2025 12:01:11 +0200 Subject: [PATCH 4/4] Fixed test --- src/Components/Web.JS/src/Virtualize.ts | 5 +- .../test/E2ETest/Tests/VirtualizationTest.cs | 76 +++++++++++++++++-- .../BasicTestApp/VirtualizationScale.razor | 19 +++-- 3 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 9d4d096d16d8..5e0bbb0e047e 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -113,16 +113,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // 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 scaleX = parseFloat(parts[0]); - const scaleY = parts.length > 1 ? parseFloat(parts[1]) : scaleX; + 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') { - // Parse the transform matrix using DOMMatrix to extract scaleY const matrix = new DOMMatrix(computedStyle.transform); - // For vertical scrolling, we only need the Y-axis scale factor (d/m22) scaleFactor *= matrix.d; } element = element.parentElement; diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index bb41229abc2a..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,16 +427,79 @@ public void CanRefreshItemsProviderResultsInPlace() name => Assert.Equal("Person 3", name)); } - [Fact] - public void CanScrollWhenAppliedScale() + [Theory] + [InlineData("1")] + [InlineData("2")] + [InlineData("0.5")] + public void CanScrollWhenAppliedScale(string scaleY) { Browser.MountTestComponent(); var container = Browser.Exists(By.Id("virtualize")); - var people = GetPeopleNames(container); - Assert.True(GetPeopleNames(container).Length > 0); - ScrollTopToEnd(Browser, container); - Assert.True(GetPeopleNames(container).Length > 0); + 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] diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor index ddbd4459c1ed..baa639ab3ec8 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationScale.razor @@ -1,13 +1,18 @@ 
- - @context.Name - + + + + @context.Id + + +
- - + + + @code { internal class Person @@ -16,8 +21,8 @@ public string Name { get; set; } = string.Empty; } - public double ScaleX { get; set; } = 1.0; - public double ScaleY { get; set; } = 1.0; + public double? ScaleX { get; set; } = 1.0; + public double? ScaleY { get; set; } = 1.0; private ItemsProviderDelegate itemsProvider = default!;