Fix Virtualize scroll jump from native/JS anchoring double-compensation#67934
Fix Virtualize scroll jump from native/JS anchoring double-compensation#67934dariatiurina wants to merge 11 commits into
Conversation
There was a problem hiding this comment.
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
refreshObserversJS interop to pass anisLoadingflag so native anchoring can be re-enabled at the right time forItemsProviderasync renders. - Update
Virtualize.tsnative-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. |
ilonatommy
left a comment
There was a problem hiding this comment.
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.
Co-authored-by: Ilona Tomkowicz <32700855+ilonatommy@users.noreply.github.com>
Co-authored-by: Ilona Tomkowicz <32700855+ilonatommy@users.noreply.github.com>
Fix Virtualize scroll jump from native/JS anchoring double-compensation
Description
Virtualizein native scroll-anchoring mode (browsers that support CSSoverflow-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,Virtualizeslid its render window — an intentional content shift — but the browser'soverflow-anchoralso 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-anchorandVirtualize'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), wherereasonis'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.nativeAnchoring.suspend('slide')disablesoverflow-anchorso 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-listspacerAfter.refreshObservedElementsnow receives anisLoadingflag and resumes anchoring unless the render was a synchronous scroll-slide (wasScrollTriggered && !isLoading). This is the key to supportingItemsProvider: an async fetch produces two renders across anawait— 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 whereVirtualizehas no JS compensation hook.ResizeObserversees 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.isLoadingvalue is the component's existing_loadingstate, forwarded fromOnAfterRenderAsyncthroughRefreshObserversAsync(bool)— no new component state was introduced.All changes are confined to the
useNativeAnchoringcode paths. The manual JS compensation path used by Safari/WebKit (nooverflow-anchorsupport) and by table layouts is unchanged.Refactor
Following review feedback, the scroll-anchoring and convergence state was consolidated:
overflow-anchorwrites were replaced by the centralizednativeAnchoringreason-counted helper described above.convergingToTop,convergingToBottom,convergingElements) and theconvergenceItemsset were consolidated into a singleconvergenceobject ({ top, bottom, observing, items, isConverging() }), and the multi-flag guards were simplified toconvergence.isConverging().These are behavior-preserving; no functional change beyond the fix above.
Testing
Adds two E2E tests to
VirtualizationTest.cs:ItemsIncrementalScroll_DoesNotJumpToStartOrEnd— scrolls incrementally through a list whose declaredItemSizediffers from the real item height and asserts the viewport never jumps to the start/end (the Virtualize unexpectedly jumps to the beginning/end of the list while scrolling after PR #65951 (.NET 11 Preview 4 regression) #67729 regression).ViewportAnchoring_RenderlessAboveViewportResize_AfterScroll_VisibleItemStaysInPlace— grows an off-screen item above the viewport via a direct DOM style mutation (no Blazor render) and asserts the visible item stays in place, verifying native anchoring is active after scrolling settles.Supporting test changes:
WaitForRenderToSettlegains an optionalitemSelectorparameter, anExecuteContainerScrollJumpDetectionScripthelper is added, andVirtualizationComponent.razorexaggerates the item-size mismatch (declared 50, actual 30) so the regression reproduces reliably.Breaking Changes
None.
RefreshObserversAsync/refreshObserversare internal framework interop; the change adds an internal parameter. No public API or behavior change for non-native-anchoring browsers.Fixes #67729