Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
}
Expand Down
76 changes: 76 additions & 0 deletions src/Components/test/E2ETest/Tests/VirtualizationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<VirtualizationScale>();
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")]
Expand Down
1 change: 1 addition & 0 deletions src/Components/test/testassets/BasicTestApp/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
<option value="BasicTestApp.VirtualizationMaxItemCount">Virtualization MaxItemCount</option>
<option value="BasicTestApp.VirtualizationMaxItemCount_AppContext">Virtualization MaxItemCount (via AppContext)</option>
<option value="BasicTestApp.VirtualizationTable">Virtualization HTML table</option>
<option value="BasicTestApp.VirtualizationScale">Virtualization with scale</option>
<option value="BasicTestApp.VirtualizationLargeOverscan">Virtualization large overscan</option>
<option value="BasicTestApp.HotReload.RenderOnHotReload">Render on hot reload</option>
<option value="BasicTestApp.SectionsTest.ParentComponentWithTwoChildren">Sections test</option>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<div style="scale: @ScaleX @ScaleY; transform-origin: top left;">
<div id="virtualize" style="height: 200px; overflow: auto">
<table>
<tbody>
<Virtualize ItemsProvider="@itemsProvider">
<tr class="person"><span>@context.Id</span></tr>
</Virtualize>
</tbody>
</table>
</div>
</div>

<input type="number" @bind="ScaleX" id="scale-x-input" />
<input type="number" @bind="ScaleY" id="scale-y-input" />
<button id="btn">Click</button>

@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<Person> itemsProvider = default!;

protected override void OnInitialized()
{
itemsProvider = async request =>
{
await Task.CompletedTask;
return new ItemsProviderResult<Person>(
items: Enumerable.Range(request.StartIndex, request.Count).Select(i => new Person { Id = i, Name = $"Person {i}" }).ToList(),
totalItemCount: 100);
};
}
}
Loading