Skip to content

Fix Virtualize table-mode scrolling in CSS Grid layouts (e.g. Aspire Dashboard)#66246

Open
ilonatommy wants to merge 1 commit intomainfrom
fix-aspire-like-spacer-miscalculation
Open

Fix Virtualize table-mode scrolling in CSS Grid layouts (e.g. Aspire Dashboard)#66246
ilonatommy wants to merge 1 commit intomainfrom
fix-aspire-like-spacer-miscalculation

Conversation

@ilonatommy
Copy link
Copy Markdown
Member

@ilonatommy ilonatommy commented Apr 9, 2026

Problem

When Virtualize is used inside a <table> with CSS Grid layout (as FluentDataGrid / Aspire Dashboard does), scrolling past the initial viewport renders a completely blank grid. Thumb-dragging the scrollbar to the middle or bottom shows no content at all.

Root cause:

  1. Spacer box lost after re-render. FluentDataGrid applies tr { display: contents } so that <td> cells participate directly in the CSS Grid. The spacer <tr> elements get this too. The JS init() sets spacerAfter.style.display = 'table-row' to give the spacer a real box, but Blazor's next re-render overwrites the entire style attribute, stripping the display override. If an IntersectionObserver callback fires before refreshObservedElements re-applies it, the spacer has offsetHeight === 0 and the OnSpacerAfterVisible callback is skipped — so the virtual window never shifts and no new items load.
  2. Item height measurement drift. The spacerSeparation value (used to derive _itemSize) is measured via Range.getBoundingClientRect().height between the two spacer elements. In CSS Grid tables where <tr> has display: contents, the Range endpoints resolve to slightly wrong positions, producing a value ~1.7% shorter than the actual sum of cell heights (e.g. 755px instead of 768px for 24 × 32px rows). This underestimate compounds across large item counts (1185 × 31.46 ≈ 37,280 vs actual 37,920), causing itemsBefore to oscillate on every render and trapping the component in a placeholder-only loop after a thumb jump.

Fix

Two targeted changes in Virtualize.ts, table-mode (isTable) only. No C# changes, no API changes, no new DOM structure.

  1. Re-apply display: table-row in processIntersectionEntries before any offsetHeight checks, closing the race window between Blazor's style overwrite and refreshObservedElements.

  2. Cell-based measurement fallback. When isTable, measure spacerSeparation from the first rendered cell's top to the last rendered cell's bottom, instead of relying on Range endpoints that misalign in CSS Grid layouts. This produces the exact item height and eliminates the redistribution oscillation.

Testing

  • New E2E test QuickGrid_AspireLikeThumbScrollToEnd_DoesNotLeaveBlankTail: 10,000-row QuickGrid with ItemSize=32 and MaxItemCount=10000, scrolls to bottom and verifies rows 9950–10000 are rendered with no blank tail.
  • Manually verified against Aspire Stress Dashboard (GET /big-trace, 1185 spans): wheel scroll, thumb jump to middle, and thumb jump to bottom all render content correctly. Previously all showed blank grid.

…akes it invisible regardless of scroll position -> no new callbacks called -> no data loaded.
@ilonatommy ilonatommy added this to the 11.0-preview4 milestone Apr 9, 2026
@ilonatommy ilonatommy self-assigned this Apr 9, 2026
@ilonatommy ilonatommy requested a review from a team as a code owner April 9, 2026 14:36
Copilot AI review requested due to automatic review settings April 9, 2026 14:36
@ilonatommy ilonatommy added the area-blazor Includes: Blazor, Razor Components label Apr 9, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes Blazor Virtualize behavior in table mode when the table is styled with CSS Grid / tr { display: contents } (e.g., FluentDataGrid / Aspire Dashboard), preventing blank/placeholder-only rendering after scrolling/jumping far down the list.

Changes:

  • Re-applies display: table-row to the spacer <tr> elements inside the IntersectionObserver callback to close a rerender race.
  • Adds a table-mode measurement fallback that derives spacerSeparation from the first/last rendered cell bounds instead of Range.getBoundingClientRect().
  • Adds an Aspire-like QuickGrid test component plus a new E2E regression test for “thumb scroll to end should not leave a blank tail”.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/Components/Web.JS/src/Virtualize.ts Makes table-mode spacers resilient to rerendered style overwrites and improves item height measurement in CSS Grid table scenarios.
src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridAspireLikeComponent.razor Introduces a new BasicTestApp component intended to mimic an Aspire-like large QuickGrid scenario for E2E validation.
src/Components/test/E2ETest/Tests/VirtualizationTest.cs Adds an E2E regression test that scrolls a large QuickGrid to the bottom and asserts the tail isn’t blank.

#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.
Comment on lines +1754 to +1757
"var c = arguments[0];" +
"var spacers = c.querySelectorAll('[aria-hidden]');" +
"var rows = c.querySelectorAll('tbody tr:not([aria-hidden])');" +
"var firstId = -1;" +
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.
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime.
To make sure no conflicting changes have occurred, please rerun validation before merging. You can do this by leaving an /azp run comment here (requires commit rights), or by simply closing and reopening.

@dotnet-policy-service dotnet-policy-service Bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants