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
22 changes: 21 additions & 1 deletion src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,16 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
return;
}

// Re-render overwrites the entire style attribute that init/refreshObservedElements applied. Re-apply it.
if (isTable) {
if (spacerBefore.style.display !== 'table-row') {
spacerBefore.style.display = 'table-row';
}
if (spacerAfter.style.display !== 'table-row') {
spacerAfter.style.display = 'table-row';
}
}

const intersectingEntries = entries.filter(entry => {
if (entry.isIntersecting) {
if (entry.target === spacerAfter) {
Expand All @@ -382,7 +392,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac

rangeBetweenSpacers.setStartAfter(spacerBefore);
rangeBetweenSpacers.setEndBefore(spacerAfter);
const spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor;
let spacerSeparation = rangeBetweenSpacers.getBoundingClientRect().height / scaleFactor;

if (isTable && spacerSeparation > 0) {
const firstTr = spacerBefore.nextElementSibling;
const lastTr = spacerAfter.previousElementSibling;
const fc = firstTr && firstTr !== spacerAfter ? firstTr.querySelector('td,th') as HTMLElement | null : null;
const lc = lastTr && lastTr !== spacerBefore ? lastTr.querySelector('td,th') as HTMLElement | null : null;
if (fc && lc) {
spacerSeparation = (lc.getBoundingClientRect().bottom - fc.getBoundingClientRect().top) / scaleFactor;
}
}

intersectingEntries.forEach((entry): void => {
const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor;
Expand Down
62 changes: 62 additions & 0 deletions src/Components/test/E2ETest/Tests/VirtualizationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1729,6 +1729,68 @@ public void QuickGrid_CanJumpToEndAndStart()
Browser.True(isFirstRowId1);
}

[Fact]
public void QuickGrid_AspireLikeThumbScrollToEnd_DoesNotLeaveBlankTail()
{
Browser.MountTestComponent<BasicTestApp.QuickGridTest.QuickGridAspireLikeComponent>();

var container = Browser.Exists(By.Id("aspire-like-grid"));
var totalItems = Browser.Exists(By.Id("aspire-total-items"));
var maxDepth = Browser.Exists(By.Id("aspire-max-depth"));
var js = (IJavaScriptExecutor)Browser;

Browser.Equal("Total items: 10000", () => totalItems.Text);
Browser.True(() => int.Parse(maxDepth.Text.Replace("Max depth: ", string.Empty, StringComparison.Ordinal), CultureInfo.InvariantCulture) >= 40);
WaitForQuickGridDataRows(container);

js.ExecuteScript("arguments[0].scrollTop = arguments[0].scrollHeight", container);

var diagnostics = new System.Text.StringBuilder();
var pollCount = 0;
Browser.True(() =>
{
pollCount++;
var metrics = (IReadOnlyDictionary<string, object>)js.ExecuteScript(
"var c = arguments[0];" +
"var spacers = c.querySelectorAll('[aria-hidden]');" +
"var rows = c.querySelectorAll('tbody tr:not([aria-hidden])');" +
"var firstId = -1;" +
Comment on lines +1754 to +1757
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JS in this test uses c.querySelectorAll('[aria-hidden]') to find Virtualize spacers, but that selector can match unrelated elements inside the grid (QuickGrid uses aria-hidden in other places). This can make spacerBefore/spacerAfter measurements unreliable. Restrict the selector to the expected spacer rows, e.g. tbody tr[aria-hidden] (and ideally assert there are exactly 2).

Copilot uses AI. Check for mistakes.
"var lastId = -1;" +
"for (var i = 0; i < rows.length; i++) {" +
" var marker = rows[i].querySelector('.aspire-row-id');" +
" if (marker) { var parsed = parseInt(marker.textContent, 10); if (!Number.isNaN(parsed)) { firstId = parsed; break; } }" +
"}" +
"for (var i = rows.length - 1; i >= 0; i--) {" +
" var marker = rows[i].querySelector('.aspire-row-id');" +
" if (marker) { var parsed = parseInt(marker.textContent, 10); if (!Number.isNaN(parsed)) { lastId = parsed; break; } }" +
"}" +
"return {" +
" remaining: c.scrollHeight - c.scrollTop - c.clientHeight," +
" scrollTop: c.scrollTop," +
" scrollHeight: c.scrollHeight," +
" clientHeight: c.clientHeight," +
" spacerBefore: spacers.length >= 1 ? spacers[0].offsetHeight : -1," +
" spacerAfter: spacers.length >= 2 ? spacers[spacers.length - 1].offsetHeight : -1," +
" firstId: firstId," +
" lastId: lastId" +
"};",
container);

var remaining = Convert.ToDouble(metrics["remaining"], 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 spacerBefore = Convert.ToDouble(metrics["spacerBefore"], CultureInfo.InvariantCulture);
var spacerAfter = Convert.ToDouble(metrics["spacerAfter"], CultureInfo.InvariantCulture);
var firstId = Convert.ToInt32(metrics["firstId"], CultureInfo.InvariantCulture);
var lastId = Convert.ToInt32(metrics["lastId"], CultureInfo.InvariantCulture);

diagnostics.AppendLine(CultureInfo.InvariantCulture, $"poll #{pollCount}: scrollTop={scrollTop:F1}, scrollHeight={scrollHeight:F1}, clientHeight={clientHeight:F1}, remaining={remaining:F1}, spacerBefore={spacerBefore:F1}, spacerAfter={spacerAfter:F1}, firstId={firstId}, lastId={lastId}");

return spacerAfter < 1 && firstId > 9950 && lastId == 10000;
}, $"Aspire-like QuickGrid should not get stranded in a large blank tail after dragging the scrollbar thumb to the bottom.{Environment.NewLine}{diagnostics}");
}

private void WaitForQuickGridDataRows(IWebElement container)
=> Browser.True(() => CheckQuickGridFirstRow(container, text => int.TryParse(text, out _)));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
@using System.Globalization
@using System.Linq
@using System.Reflection
@using Microsoft.AspNetCore.Components.QuickGrid
@using Microsoft.AspNetCore.Components.Web.Virtualization

<style>
#aspire-like-grid {
height: 476px;
overflow: auto;
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component is meant to reproduce the CSS Grid table scenario described in the PR (notably tr { display: contents; } as used by FluentDataGrid/Aspire). Currently the only styling is height/overflow on the container, so the Virtualize table-mode regression may not be exercised and the E2E could pass even without the JS fix. Consider adding Aspire-like table/grid CSS (e.g., apply display: grid to table.quickgrid and display: contents to tbody/tr, plus a simple grid-template-columns) so the test actually covers the reported layout.

Suggested change
}
}
#aspire-like-grid table.quickgrid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 120px);
width: 100%;
}
#aspire-like-grid table.quickgrid thead,
#aspire-like-grid table.quickgrid tbody,
#aspire-like-grid table.quickgrid tr {
display: contents;
}
#aspire-like-grid table.quickgrid th,
#aspire-like-grid table.quickgrid td {
min-width: 0;
}

Copilot uses AI. Check for mistakes.
</style>

<p id="aspire-total-items">Total items: @_totalItemCount</p>
<p id="aspire-max-depth">Max depth: @_maxDepth</p>

<div id="aspire-like-grid">
<QuickGrid @ref="_grid" ItemsProvider="@_itemsProvider" Virtualize="true" ItemSize="32">
<TemplateColumn Title="Name">
<span class="aspire-row-id" hidden>@context.Id</span>
<span>@context.Name</span>
</TemplateColumn>
<PropertyColumn Property="@(r => r.Resource)" />
<PropertyColumn Property="@(r => r.Duration)" />
</QuickGrid>
</div>

@code {
private static readonly IReadOnlyList<AspireLikeRow> s_allRows = CreateRows();

private QuickGrid<AspireLikeRow> _grid = default!;
private GridItemsProvider<AspireLikeRow> _itemsProvider = default!;
private int _totalItemCount = s_allRows.Count;
private int _maxDepth = s_allRows.Max(r => r.Depth);

internal sealed class AspireLikeRow
{
public int Id { get; set; }
public int Depth { get; set; }
public bool HasChildren { get; set; }
public string Name { get; set; } = string.Empty;
public string Resource { get; set; } = string.Empty;
public string Duration { get; set; } = string.Empty;
}

protected override void OnInitialized()
{
_itemsProvider = async request =>
{
await Task.Yield();

var items = s_allRows
.Skip(request.StartIndex)
.Take(request.Count ?? 100)
.ToList();

return GridItemsProviderResult.From(items, totalItemCount: _totalItemCount);
};
}

protected override void OnAfterRender(bool firstRender)
{
if (_grid is null)
{
return;
}

var virtualizeField = typeof(QuickGrid<AspireLikeRow>).GetField("_virtualizeComponent", BindingFlags.Instance | BindingFlags.NonPublic);
if (virtualizeField?.GetValue(_grid) is not Virtualize<(int, AspireLikeRow)> virtualize)
{
return;
}

if (virtualize.MaxItemCount != 10_000)
{
virtualize.MaxItemCount = 10_000;
StateHasChanged();
}
}

private static List<AspireLikeRow> CreateRows()
{
const int targetItemCount = 10_000;
var rows = new List<AspireLikeRow>(targetItemCount);
var nextId = 1;

for (var traceGroup = 0; rows.Count < targetItemCount; traceGroup++)
{
rows.Add(new AspireLikeRow
{
Id = nextId++,
Depth = 1,
HasChildren = true,
Name = $"GET /big-trace/{traceGroup}",
Resource = "Stress.ApiService",
Duration = "46.37s",
});

var depthLimit = 20 + (traceGroup % 28);

for (var depth = 1; depth <= depthLimit && rows.Count < targetItemCount; depth++)
{
rows.Add(new AspireLikeRow
{
Id = nextId++,
Depth = depth + 1,
HasChildren = depth < depthLimit,
Name = $"bigtrace-Span-{traceGroup}-{depth}",
Resource = depth % 5 == 0 ? "Stress.Worker" : "Stress.ApiService",
Duration = FormattableString.Invariant($"{Math.Max(0.03, 4.25 - (depth * 0.07)):0.00}s"),
});
}
}

return rows;
}
}
Loading