Skip to content

[Blazor] Keep Virtualize spacer callbacks programmatic while an alignment is pending - #68709

Open
lewing wants to merge 2 commits into
mainfrom
lewing-fix-virtualize-pending-align-classificat
Open

[Blazor] Keep Virtualize spacer callbacks programmatic while an alignment is pending#68709
lewing wants to merge 2 commits into
mainfrom
lewing-fix-virtualize-pending-align-classificat

Conversation

@lewing

@lewing lewing commented Aug 22, 2026

Copy link
Copy Markdown
Member

Fixes #68708

Problem

VirtualizationTest.QuickGrid_InitialIndex_TallContainer_NearEnd_FillsViewportWithoutUserScroll has been failing intermittently on main and on unrelated PRs since #67936 merged, on both the Mono and CoreCLR E2E legs, in both the WebAssembly and Interactive Server variants, and for both useProvider cases.

Item 950 should remain aligned with the viewport top once the last item has loaded,
but top rendered index was -1, scrollTop=0.

The DOM captured at failure time explains the -1: the before-spacer is 127588px, the rendered window is items 977–999, and scrollTop is 0. The viewport is entirely covered by the before-spacer, so no item intersects it.

Root cause

  1. InitialItemIndex = 950 puts the component in InitialIndexPhase.Pending and calls JS alignToItem.
  2. alignToItemAt finds the target is not in the committed window yet, so it records pendingAlignLocalIndex and returns without scrolling. scrollTop is still 0.
  3. Because it returns immediately, ScrollToItemAsyncCore's finally clears _currentScrollCts while the alignment is still pending in JS.
  4. Fix  InitialItemIndex viewport underfill for small items in big container or on window resize #67936 made processIntersectionEntries call scrollActivity.clear() whenever source === AlignToItem — including when the alignment is merely deferred. That resets the source to None, so the following spacer callbacks are classified ViewportFill instead of ProgrammaticScroll.
  5. Fix  InitialItemIndex viewport underfill for small items in big container or on window resize #67936 also removed the || _initialIndex.Phase == InitialIndexPhase.Pending clause from the C# ViewportFill early-return. With _currentScrollCts already null, C# now acts on those callbacks during Pending and runs UpdateWindowFromViewport.
  6. The pending alignment is abandoned with scrollTop never applied. The ordinary end-of-list fill then sets itemsBefore = _itemCount - visibleItemCapacity = 1000 - 23 = 977, reproducing the captured DOM exactly.

It is intermittent because it depends on an IntersectionObserver callback landing between the deferred alignment and its retry. The Interactive Server variant fails most often, since SignalR round-trips widen that window.

Fix

Only end the align scroll activity once the alignment has actually landed:

function canEndAlignActivity(source: ScrollSource): boolean {
  return source === ScrollSource.AlignToItem && pendingAlignLocalIndex === null;
}

While an alignment is pending, spacer callbacks stay ProgrammaticScroll, which C# ignores, so the window is not redistributed out from under it. C# already re-drives AlignToTargetAsync on every render while the phase is Pending, so the alignment converges once the DOM catches up.

This deliberately does not restore the C# Pending guard removed in #67936 — that guard is what enables the viewport-underfill growth that PR added. Keeping the callbacks classified as programmatic addresses the race without giving up that behavior.

Testing

src/Components/Web.JS/test/Virtualize.test.ts was a 13-line export smoke test, so none of this state machine was covered. Added VirtualizePendingAlign.test.ts, which drives init / beginProgrammaticScroll / alignToItem against a stubbed IntersectionObserver and a DOM mirroring the near-end layout:

Verified red/green:

Virtualize.ts pending-align test landed-align test
fde48e9521^ (pre-#67936) pass pass
main (post-#67936) fail pass
this PR pass pass

Full Web.JS jest suite: 208 passing, up from 206 at baseline. The 5 failing suites are pre-existing Cannot find module '@microsoft/signalr' resolution errors, identical with and without this change. eslint reports no new problems in the edited region.

Follow-up

src/Components/Web/test/Virtualization/VirtualizeTest.cs still has no InitialItemIndex coverage at all. Worth adding separately.

alignToItemAt returns without scrolling when the target item is not in the
committed window yet, recording pendingAlignLocalIndex so a later render can
retry. processIntersectionEntries cleared the AlignToItem scroll activity
regardless, which downgraded subsequent spacer callbacks from
ProgrammaticScroll (ignored by C#) to ViewportFill.

Virtualize also clears _currentScrollCts as soon as AlignToItemAsync returns,
so those ViewportFill callbacks arrive with no guard left and C# redistributes
the window while InitialIndexPhase is still Pending. The alignment is abandoned
with scrollTop never applied, and the ordinary end-of-list fill then parks the
window at _itemCount - visibleItemCapacity, leaving the viewport covered by the
before-spacer and no items rendered in it.

Only end the align activity once the alignment has actually landed. A completed
alignment still hands control back, so viewport fill can top up the window.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01389ba9-a838-43b0-81d5-aa36f1f7f82a

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes an intermittent Blazor E2E failure where Virtualize can abandon a deferred InitialItemIndex alignment due to spacer callbacks being misclassified while the JS-side alignment is still pending. The change keeps spacer callbacks treated as programmatic until the alignment actually lands, preventing C# from redistributing the window mid-alignment.

Changes:

  • Update Virtualize.ts to only clear AlignToItem scroll activity once there is no pending align (pendingAlignLocalIndex === null).
  • Add a focused Jest test suite that simulates the near-end InitialItemIndex layout and validates callback classification both while alignment is pending and after it lands.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Components/Web.JS/src/Virtualize.ts Adds a guard (canEndAlignActivity) so AlignToItem scroll activity is only ended once deferred alignment has completed.
src/Components/Web.JS/test/VirtualizePendingAlign.test.ts Adds state-machine coverage for the pending-align race by stubbing IO + DOM geometry and asserting spacer callback reasons.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Components/Web.JS/test/VirtualizePendingAlign.test.ts
Use runOnlyPendingTimers so the test does not depend on THROTTLE_MS.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01389ba9-a838-43b0-81d5-aa36f1f7f82a
@lewing
lewing marked this pull request as ready for review August 22, 2026 22:42
@lewing
lewing requested a review from a team as a code owner August 22, 2026 22:42
@lewing
lewing requested a review from ilonatommy August 22, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Known Build Error] VirtualizationTest.QuickGrid_InitialIndex_TallContainer_NearEnd_FillsViewportWithoutUserScroll — top rendered index was -1

2 participants