Skip to content

Fix Virtualize scroll jump from native/JS anchoring double-compensation#67934

Open
dariatiurina wants to merge 11 commits into
dotnet:mainfrom
dariatiurina:fix-67729-virtualize-anchoring
Open

Fix Virtualize scroll jump from native/JS anchoring double-compensation#67934
dariatiurina wants to merge 11 commits into
dotnet:mainfrom
dariatiurina:fix-67729-virtualize-anchoring

Conversation

@dariatiurina

@dariatiurina dariatiurina commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Fix Virtualize scroll jump from native/JS anchoring double-compensation

Description

Virtualize in native scroll-anchoring mode (browsers that support CSS overflow-anchor — i.e. not Safari, and not <table> layouts) could jump to the start or end of the list while the user scrolled. When a scroll brought a spacer into view, Virtualize slid its render window — an intentional content shift — but the browser's overflow-anchor also tried to hold the content still. Both authorities compensated the same shift, the viewport overshot, and edge convergence then latched it to the top or bottom. This was a regression from #65951, which introduced native anchoring.

This PR makes native overflow-anchor and Virtualize's own scroll handling cooperate so that exactly one authority acts per render, without weakening the manual-compensation path used by Safari and tables.

How it works

Native anchoring is now controlled through a single, reason-counted helper (nativeAnchoring.suspend(reason) / resume(reason), where reason is 'slide' or 'convergence'). Anchoring stays suspended while any reason holds it and is restored only once every reason has resumed, so the two concerns can't re-enable it behind each other's back.

  • Suspend native anchoring for the intentional slide. When a spacer becomes visible and a scroll-triggered render begins, nativeAnchoring.suspend('slide') disables overflow-anchor so the browser doesn't fight the window slide (the root cause of Virtualize unexpectedly jumps to the beginning/end of the list while scrolling after PR #65951 (.NET 11 Preview 4 regression) #67729). This is only done when a spacer callback (and the render it triggers) will actually be raised, so it is never left suspended for a no-op intersection such as a zero-height end-of-list spacerAfter.
  • Resume after the render commits, except for a synchronous scroll-slide. refreshObservedElements now receives an isLoading flag and resumes anchoring unless the render was a synchronous scroll-slide (wasScrollTriggered && !isLoading). This is the key to supporting ItemsProvider: an async fetch produces two renders across an await — a placeholder render (isLoading == true) and then a data render (isLoading == false). Anchoring is resumed at the placeholder render so the browser is armed to compensate the placeholder→real height change on the following data render — a point where Virtualize has no JS compensation hook.
  • Resume after scrolling settles. When the ResizeObserver sees the spacers re-measured after a slide, anchoring is resumed. This covers render-less above-viewport resizes (a pure DOM reflow with no Blazor render), which only the browser can compensate, and ensures anchoring is never left stuck off.
  • The isLoading value is the component's existing _loading state, forwarded from OnAfterRenderAsync through RefreshObserversAsync(bool) — no new component state was introduced.

All changes are confined to the useNativeAnchoring code paths. The manual JS compensation path used by Safari/WebKit (no overflow-anchor support) and by table layouts is unchanged.

Refactor

Following review feedback, the scroll-anchoring and convergence state was consolidated:

  • The scattered overflow-anchor writes were replaced by the centralized nativeAnchoring reason-counted helper described above.
  • The loose convergence flags (convergingToTop, convergingToBottom, convergingElements) and the convergenceItems set were consolidated into a single convergence object ({ top, bottom, observing, items, isConverging() }), and the multi-flag guards were simplified to convergence.isConverging().

These are behavior-preserving; no functional change beyond the fix above.

Testing

Adds two E2E tests to VirtualizationTest.cs:

Supporting test changes: WaitForRenderToSettle gains an optional itemSelector parameter, an ExecuteContainerScrollJumpDetectionScript helper is added, and VirtualizationComponent.razor exaggerates the item-size mismatch (declared 50, actual 30) so the regression reproduces reliably.

Breaking Changes

None. RefreshObserversAsync/refreshObservers are internal framework interop; the change adds an internal parameter. No public API or behavior change for non-native-anchoring browsers.

Fixes #67729

@dariatiurina dariatiurina self-assigned this Jul 21, 2026

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 regression in Blazor’s Virtualize where native CSS scroll anchoring (overflow-anchor) could fight Virtualize’s intentional render-window “slide” during scroll-triggered renders, causing the viewport to jump to the start/end of the list. The change makes native anchoring cooperate with Virtualize so that only one “authority” compensates per render, and adds E2E coverage to prevent regressions.

Changes:

  • Extend the refreshObservers JS interop to pass an isLoading flag so native anchoring can be re-enabled at the right time for ItemsProvider async renders.
  • Update Virtualize.ts native-anchoring logic to temporarily disable anchoring during scroll-triggered window slides and restore it when scrolling/renders settle.
  • Add E2E tests plus test-asset adjustments to reproduce and validate the regression fix and render-less reflow anchoring behavior.

Reviewed changes

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

Show a summary per file
File Description
src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs Extends interop method signature to forward isLoading into JS.
src/Components/Web/src/Virtualization/Virtualize.cs Forwards component _loading state into observer refresh after renders.
src/Components/Web.JS/src/Virtualize.ts Adjusts native anchoring suppression/restoration logic around scroll-triggered renders and “settled” scroll state.
src/Components/test/testassets/BasicTestApp/VirtualizationComponent.razor Exaggerates item size mismatch to reliably reproduce the regression in tests.
src/Components/test/E2ETest/Tests/VirtualizationTest.cs Adds E2E coverage for scroll-jump regression and for render-less above-viewport resize anchoring.

Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/test/E2ETest/Tests/VirtualizationTest.cs Outdated
@dariatiurina
dariatiurina marked this pull request as ready for review July 21, 2026 12:39
@dariatiurina
dariatiurina requested a review from a team as a code owner July 21, 2026 12:39
@dariatiurina
dariatiurina requested a review from ilonatommy July 21, 2026 12:39
@dariatiurina dariatiurina changed the title Virtualize fix anchoring Fix Virtualize scroll jump from native/JS anchoring double-compensation Jul 21, 2026

@ilonatommy ilonatommy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Didn't test yet but looks logical and not too drastic. The changes made me consider to simplify the code a bit. This PR is a good opportunity. Beyond the ideas below, I think we should have a more centralized place to control native/manual anchoring switching. Somehting like:

const nativeAnchoring = {
  suspendedFor: new Set<'convergence' | 'slide'>(),

  suspend(reason: 'convergence' | 'slide'): void {
    this.suspendedFor.add(reason);
    if (useNativeAnchoring) {
      scrollElement.style.overflowAnchor = 'none';
    }
  },

  resume(reason: 'convergence' | 'slide'): void {
    this.suspendedFor.delete(reason);
    if (useNativeAnchoring && this.suspendedFor.size === 0) {
      scrollElement.style.overflowAnchor = '';
    }
  },
};

and e.g. in startConvergenceObserving from L442-443 where we do if(useNativeAnchoring) …='none', we would call nativeAnchoring.suspend('convergence') instead.

Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
@ilonatommy ilonatommy added this to the 11.0-rc1 milestone Jul 22, 2026
@ilonatommy ilonatommy added area-blazor Includes: Blazor, Razor Components feature-blazor-virtualization This issue is related to the Blazor Virtualize component labels Jul 22, 2026
@dariatiurina
dariatiurina requested a review from ilonatommy July 22, 2026 09:05
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/test/E2ETest/Tests/VirtualizationTest.cs
Comment thread src/Components/test/E2ETest/Tests/VirtualizationTest.cs Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
dariatiurina and others added 3 commits July 22, 2026 13:31
Co-authored-by: Ilona Tomkowicz <32700855+ilonatommy@users.noreply.github.com>
Co-authored-by: Ilona Tomkowicz <32700855+ilonatommy@users.noreply.github.com>

@ilonatommy ilonatommy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool. Thank you!

@dariatiurina
dariatiurina enabled auto-merge (squash) July 22, 2026 12:07
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 feature-blazor-virtualization This issue is related to the Blazor Virtualize component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Virtualize unexpectedly jumps to the beginning/end of the list while scrolling after PR #65951 (.NET 11 Preview 4 regression)

3 participants