Skip to content

[Blazor] Re-derive Virtualize at-bottom state when the anchor mode changes - #68748

Open
lewing wants to merge 2 commits into
mainfrom
lewing-fix-virtualize-end-anchor-latch
Open

[Blazor] Re-derive Virtualize at-bottom state when the anchor mode changes#68748
lewing wants to merge 2 commits into
mainfrom
lewing-fix-virtualize-end-anchor-latch

Conversation

@lewing

@lewing lewing commented Aug 24, 2026

Copy link
Copy Markdown
Member

Fixes the flake that #68731 quarantined, and re-enables the test.

Problem

VirtualizationTest.QuickGrid_AnchorMode_End_PrependAtTop_ViewportStaysStable failed 3 times in 30 days on main, always as ServerVirtualizationTest on the Mono E2E leg with useItemsProvider: True:

Scroll assertion failed: expected QuickGrid should start at the top,
but scrollTop=54778, scrollHeight=55078, clientHeight=300, maxScrollTop=54778

scrollTop == maxScrollTop, with byte-identical numbers across all three failures — the grid is pinned to the bottom of the list at a point where the test has not scrolled at all.

Root cause

isViewportAtBottom() treats a container that is not yet scrollable as being at the bottom:

const isViewportAtBottom = (): boolean =>
  scrollElement.scrollHeight <= scrollElement.clientHeight
  || Math.abs(scrollElement.scrollTop + scrollElement.clientHeight - scrollElement.scrollHeight) < 2;

bottomTracking.wasAtBottomLastRender is written from that predicate on every render, and outside of clearBottomFollow it is only reset by a user scroll. This test never scrolls — scrollTop stays 0 from mount until the assertion.

So an empty first render, or an async ItemsProvider round trip that briefly leaves the container shorter than its viewport, latches wasAtBottomLastRender = true and it stays latched. setAnchorMode then refreshed following and reached but left that stale flag alone:

setAnchorMode: (mode) => { anchorMode = mode; bottomTracking.following = (mode & 2) !== 0; bottomTracking.reached = isViewportAtBottom(); },

Selecting End mode therefore satisfied the re-pin guard in refreshObservedElements on the next render:

if ((anchorModeIs.end || bottomTracking.following) && (bottomTracking.wasAtBottomLastRender || bottomTracking.reached)) {
  scrollElement.scrollTop = scrollElement.scrollHeight;

It is intermittent because it depends on a render landing while the container is not scrollable. Interactive Server with a delayed provider widens that window, which matches the observed parameterization exactly.

Introduced by #67639 (bf41edc66b, Jul 20), which added the bottomTracking state machine and the wasAtBottomLastRender term in the guard. First failure Jul 30.

Fix

Re-derive all three flags from the live viewport when the anchor mode changes, mirroring what the user-scroll path already does. This also matches the existing reasoning in restoreAnchorForShift"Don't rely on the cached wasAtBottomLastRender — it may be stale" — which setAnchorMode was not honoring.

Also removes the [QuarantinedTest] attribute added by #68731, since the underlying cause is fixed here.

Testing

Virtualize.test.ts was a 13-line export smoke test, so none of this state machine was covered. Added VirtualizeEndAnchorLatch.test.ts, which drives init / refreshObservers / setAnchorMode against stubbed observers and a DOM whose geometry can model "provider in flight" versus "data loaded":

  • switching into End mode at the top does not jump to the bottom after a transient short list — fails without the fix with Received: 55078. The browser clamps that to 55078 - 300 = 54778, reproducing the CI value exactly. Passes with the fix.
  • End mode still follows appends once the viewport has actually reached the bottom — passes with and without the fix, so the first test is not vacuous and Improve AnchorMode tests #67639's intended End-mode behavior is still exercised.

Full Web.JS jest suite: 208 passing, 0 failing. The 5 failing suites are pre-existing Cannot find module '@microsoft/signalr' resolution errors, identical with and without this change. eslint on Virtualize.ts goes from 40 to 38 reported problems — no new ones.

E2E confirmation depends on CI here, since the failure needs the Interactive Server + delayed-provider timing window.

Notes for reviewers

Unrelated: the Aug 23 quarantine batch looks misattributed

The five quarantine PRs from Aug 23 cite a "most recent failing build" each. For three of them, that build's complete test-result set does not contain a failure of the test being quarantined:

The pattern fits resolving the stack-trace line number against current main rather than using the fully-qualified test name that is already in the trace, so a real failure gets attributed to whichever test now occupies that line:

Observed failure counts versus what the PRs claimed:

Merged PR Quarantined test Claimed Observed
#68727 QuickGrid_AnchorMode_NearTop_AppendKeepsViewportStable 4 1
#68729 AnchorMode_Start_LargePrependAtTop_StillShowsNewItems 3 0

Scope of that check, so the limits are explicit: every aspnetcore-components-e2e build — the only pipeline that runs these, since aspnetcore-ci Helix legs contain no Components.E2ETest results — across all branches including 201 PR builds, counting every non-passing outcome, over the 392 builds from Jul 27 to Aug 24. AzDO has purged def-87 builds older than Jul 27, so roughly the first two days of the PRs' 30-day window are no longer observable.

The single NearTop failure was build 1562094 on PR #68664, an unrelated Kestrel DirectTLS change: Assert.NotNull() Failure: Value is null in GetItemPositionInContainer — a different signature from the scroll-position failures, and not the defect fixed here.

Two follow-ons regardless: already-quarantined failures should not seed new quarantines, and the batch missed a genuinely un-quarantined flake, QuickGrid_AnchorMode_End_AppendAfterLeavingBottom_DoesNotReengage (2 live failures), which shares the async-provider/Server/Mono signature and may share this PR's root cause.

Copilot AI lite review requested due to automatic review settings August 24, 2026 16:17
@lewing
lewing requested a review from a team as a code owner August 24, 2026 16:17
lewing and others added 2 commits August 24, 2026 11:20
…anges

`setAnchorMode` refreshed `bottomTracking.following` and `bottomTracking.reached`
but left `wasAtBottomLastRender` untouched. `isViewportAtBottom()` reports true for
any container that is not yet scrollable, so an empty first render or an async
ItemsProvider round trip latches that flag, and it is otherwise only cleared by a
user scroll. Switching into End mode afterwards satisfied the re-pin guard in
`refreshObservedElements` and jumped the viewport to the bottom even though the
user was parked at the top.

Re-derive all three flags from the live viewport, mirroring what the user-scroll
path already does.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 50118332-337f-40e9-ae34-1a373d3e8550
#68731 quarantined this test while the fix was in review. The anchor-mode
latch it was tripping over is fixed in this PR, so re-enable it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 50118332-337f-40e9-ae34-1a373d3e8550
@lewing
lewing force-pushed the lewing-fix-virtualize-end-anchor-latch branch from 013fc31 to 43fe790 Compare August 24, 2026 16:21

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

This PR fixes a Blazor Virtualize/QuickGrid flake where switching into AnchorMode.End could incorrectly “pin” the viewport to the bottom due to a stale cached “at-bottom” flag captured during a transient non-scrollable render (e.g., async ItemsProvider in-flight).

Changes:

  • Re-derives bottomTracking at-bottom state (reached and wasAtBottomLastRender) from the live viewport when setAnchorMode is invoked.
  • Adds a focused Jest regression test that simulates the transient “short list” window and validates that switching into End mode while at top does not jump to bottom.
  • Adds an additional Jest test asserting End-mode follow behavior on append.

Reviewed changes

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

File Description
src/Components/Web.JS/src/Virtualize.ts Recomputes all at-bottom tracking flags when anchor mode changes to prevent stale-state re-pinning.
src/Components/Web.JS/test/VirtualizeEndAnchorLatch.test.ts Adds regression coverage for the End-mode “at-bottom latch” scenario and for End-mode follow-on-append behavior.

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

Comment on lines +135 to +145
// User scrolls to the bottom of the loaded list.
container.scrollTop = LOADED_SCROLL_HEIGHT - CLIENT_HEIGHT;
container.dispatchEvent(new Event('scroll'));
Virtualize.refreshObservers(helper, false);

// New items are appended.
geometry.scrollHeight = LOADED_SCROLL_HEIGHT + 500;
Virtualize.refreshObservers(helper, false);

expect(container.scrollTop).toBe(geometry.scrollHeight);
});
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.

2 participants