Virtualization AnchorMode with for variable-height support#66262
Virtualization AnchorMode with for variable-height support#66262ilonatommy merged 29 commits intodotnet:mainfrom
AnchorMode with for variable-height support#66262Conversation
There was a problem hiding this comment.
Pull request overview
Adds an AnchorMode API to Virtualize<TItem> and implements a DOM-measurement-based anchor snapshot/restore mechanism in the Web.JS virtualization runtime to keep the viewport stable (including for variable-height items) when content shifts.
Changes:
- Introduces
VirtualizeAnchorModeand theVirtualize<TItem>.AnchorModeparameter, flowing mode into JS initialization and supporting dynamic updates. - Implements JS-side anchor snapshotting (first visible element + relative position) and post-render restoration with suppression of stale IntersectionObserver callbacks.
- Adds/extends E2E + JS export tests plus BasicTestApp scenarios to validate prepend/append behavior across modes (including window scroll).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs | Passes anchorMode into JS init; adds interop methods for mode updates and anchor restoration. |
| src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs | New public flags enum defining None/Beginning/End. |
| src/Components/Web/src/Virtualization/Virtualize.cs | Adds AnchorMode parameter and wires up post-render mode updates + anchor restoration signaling. |
| src/Components/Web/src/PublicAPI.Unshipped.txt | Declares the new public API surface (enum + parameter). |
| src/Components/Web.JS/src/Virtualize.ts | Adds mode gating, anchor snapshot/restore logic, and suppression of stale IO callbacks. |
| src/Components/Web.JS/test/Virtualize.test.ts | Verifies new JS exports are present. |
| src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor | Manual test page for anchor mode + variable height in a scroll container. |
| src/Components/test/testassets/BasicTestApp/VirtualizationAnchorModeWindowScroll.razor | Manual test page for anchor mode in window scrolling mode. |
| src/Components/test/testassets/BasicTestApp/Index.razor | Adds navigation entries to the new manual test pages. |
| src/Components/test/E2ETest/Tests/VirtualizationTest.cs | Adds E2E coverage for mode behaviors (none/beginning/end), variable-height cases, and window scroll. |
bf83b9b to
2d0488c
Compare
…ight support Adds a new AnchorMode parameter to the Virtualize component that controls how the viewport behaves at list edges when items are added dynamically. New API: - VirtualizeAnchorMode enum: None (0), Beginning (1), End (2) [Flags] - Virtualize<TItem>.AnchorMode parameter (default: Beginning) Anchor snapshot/restore mechanism: - JS lazily captures the first visible item's position on every IO callback - After a render that shifts content (prepend/append/redistribution), JS restores the anchored item to its original viewport position using measured DOM positions instead of average-height estimates - Works correctly for both fixed and variable height items - Stale IO callbacks are suppressed after anchor restore to prevent undo Behavior matrix: | Scenario | None | Beginning | End | |---------------------|-------------------|-------------------|-------------------| | Prepend at top | Viewport stable | Shows new items | Viewport stable | | Append at bottom | Viewport stable | Auto-scrolls | Auto-scrolls | | Mid-list changes | Viewport stable | Viewport stable | Viewport stable | | Home/End keys | Works | Works | Works | Contributes to dotnet#65742, dotnet#26943. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2d0488c to
2275f1e
Compare
fd1070e to
703a710
Compare
…tability fixes. - Fix IO suppression race: unified suppressSpacerCallbacks flag persists until next user scroll, preventing IntersectionObserver re-entry from undoing anchor restore scroll compensation. - Fix End+variable-height large append: JS-side wasAtBottom detection in refreshObservedElements starts scroll-to-bottom convergence directly, avoiding unreliable C# race conditions. - Remove stale C# _pendingScrollToBottom branch from RefreshDataCoreAsync that caused false re-engage when user scrolled away from bottom. - Fix prepend ordering: items now appear -10,-9,...,-1 instead of -1,-2,...-10. - Fix LargePrependAtTop test to check for -100 (first item after Reverse). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
703a710 to
a278a21
Compare
…d properly on expansion.
a854543 to
4160749
Compare
…m the edge + remove drift correction because it was causing sub-pixel shifts.
…llection outside of the item provider call.
…mode should converge for them.
…herwise it's working.
…ems when new param is not provided.
dariatiurina
left a comment
There was a problem hiding this comment.
Thank you for your work! A few notes, but otherwise, looks good to me.
There was a problem hiding this comment.
I checked this page with scale: 0.85. The behaviour is the same as it was with ItemsProvider with waiting. Do we want to fix it now, or make a follow-up PR with the fix?
|
/backport to release/11.0-preview4 |
|
Started backporting to |
|
@ilonatommy backporting to git am output$ git am --3way --empty=keep --ignore-whitespace --keep-non-patch changes.patch
Applying: Add VirtualizeAnchorMode with anchor snapshot/restore for variable-height support
Using index info to reconstruct a base tree...
M src/Components/Web/src/PublicAPI.Unshipped.txt
M src/Components/test/E2ETest/Tests/VirtualizationTest.cs
M src/Components/test/testassets/BasicTestApp/Index.razor
Falling back to patching base and 3-way merge...
Auto-merging src/Components/Web/src/PublicAPI.Unshipped.txt
CONFLICT (content): Merge conflict in src/Components/Web/src/PublicAPI.Unshipped.txt
Auto-merging src/Components/test/E2ETest/Tests/VirtualizationTest.cs
Auto-merging src/Components/test/testassets/BasicTestApp/Index.razor
error: Failed to merge in the changes.
hint: Use 'git am --show-current-patch=diff' to see the failed patch
hint: When you have resolved this problem, run "git am --continue".
hint: If you prefer to skip this patch, run "git am --skip" instead.
hint: To restore the original branch and stop patching, run "git am --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Patch failed at 0001 Add VirtualizeAnchorMode with anchor snapshot/restore for variable-height support
Error: The process '/usr/bin/git' failed with exit code 128 |
…nd detection with a fix for it.
|
/ba-g template test failures unrelated - we don't have Virtualization in templates and this PR didn't touch that part |
Summary
Adds a new
AnchorModeparameter to the Virtualize component that controls how the viewport behaves at list edges when items are added dynamically. Unlike the previous approach (#66073) that estimated scroll compensation from average item heights, this uses a measure-based anchor snapshot/restore mechanism that works correctly for both fixed and variable-height items.Closes #66073.
Fixes #65742.
Contributes to #26943 (the snapshot mechanism will be reused for scroll-to-row).
variable-anchoring.mp4
New API
Virtualize.AnchorMode- default is Beginning (backward-compatible with .NET 10 behavior).Modes can be combined:
Beginning | Endpins both edges. CombiningNonewith other modes is supported but does not change the combined value → sinceNone = 0the combinationNone | Beginningis just Beginning.Virtualize.ItemComparer— optional. Required for anchor stability when usingItemsProviderwith class-typed items. Not needed for in-memoryItems(usesReferenceEqualson the first loaded item instead). For records, the default comparer's value-equality behavior works automatically.A Roslyn analyzer (
BL0011) warns at compile time whenVirtualizeis used withItemsProviderbut withoutItemComparer, so users get a clear hint that anchor stability requires a comparer in that configuration.Behavior matrix
How it works
Previous approaches estimated scroll compensation using
countDelta × averageItemHeight, which breaks for variable-height items. This PR replaces that with actual DOM measurement:1. Continuous snapshot
JS saves the first visible item's
anchorItemIndex(DOM child position from spacerBefore) andanchorOffset(viewport-relative pixel position) on everyIntersectionObservercallback, everyscrollevent, and after every render cycle (refreshObservedElements). This keeps the anchor fresh as the user scrolls between renders, not just when IO fires.2. Prepend/append detection (C#)
When the item count increases, C# detects whether items were prepended or appended:
Items: Compares the first loaded item viaReferenceEqualsagainst the previously stored reference (_previousFirstLoadedItem). If the identity changed, a prepend is detected.ItemsProvider+ItemKey: CallsItemKeyon the first returned item and compares withEqualsagainst the previously stored key (_previousFirstLoadedItemKey). If the key changed, a prepend is detected.ItemsProviderwithoutItemKey: No detection possible. A debug warning is logged. Native scroll anchoring may partially help, but stability is not guaranteed.When a prepend is detected, C# shifts the rendered window (
_itemsBefore += countDelta) viaAdjustForPrependAsyncso the same items stay in the DOM at the same child positions, and sets_pendingAnchorRestore = trueto signal JS.For appends near the bottom in non-End modes,
ShouldAnchorForAppendsets_pendingAnchorRestore = trueto prevent the viewport from chasing new items via spacer redistribution. In End mode, no anchor restore is signaled — JS handles convergence to bottom instead.3. Restore after render
In
OnAfterRenderAsync, if_pendingAnchorRestoreis set, C# callsRestoreAnchorAsync()(no parameters — JS reads the snapshot directly). JS then decides what to do based on anchor mode and scroll position:scrollTop < 1): Starts convergence to top (convergingToTop = true,scrollTop = 0,startConvergenceObserving). This explicitly pins the viewport to the top so the user sees new items, and works consistently across all browsers regardless of native scroll anchoring support.anchorItemIndex-th child, measures the pixel delta between its current and saved position, and adjustsscrollTopby the exact delta. Also saves drift correction data (scrollCorrectionItemIndex+ offset) sorefreshObservedElementscan correct any residual drift after spacer→item redistribution.Example — prepend with 3 items:
#5is at 120px from the container top (snapshot saved)._itemsBeforeadjusted so the same items stay at the same child positions, but spacerBefore grows by ~150px, pushing everything down.#5at 270px, computesdelta = 270 - 120 = 150px, adjustsscrollTop += 150.4. Stale callback suppression
After adjusting
scrollTop, all spacerIntersectionObservercallbacks are suppressed (suppressSpacerCallbacks = true) until the next user-initiated scroll or Home/End key press. Without this, the IO callback would recalculate_itemsBeforewith pre-adjustment intersection data and undo the scroll compensation.Additionally, the
ignoreAnchorScrollflag is set so the scroll event triggered by thescrollTopadjustment itself is not mistaken for a user scroll.On the next user scroll, suppression is cleared, drift correction data is saved for the visible anchor item, and spacers are re-observed (
reobserveSpacers) so IO fires fresh with the correct scroll position. Home/End keys also clear suppression and re-observe immediately.5. Drift correction
After a spacer→item redistribution (IO fires, C# recalculates
_itemsBefore, re-renders), the previously anchored item may shift slightly due to height estimation differences.refreshObservedElementschecks for pending drift correction data (saved during anchor restore or user scroll), walks the DOM to find the item by child index, measures the delta, and applies a scrollTop correction if the drift exceeds 1px.Convergence gating
The existing top/bottom convergence logic (which auto-scrolls to edges while items load) is now gated on
anchorMode:restoreAnchorForShiftwhen Beginning mode is active andscrollTop < 1, or viaonSpacerBeforeVisiblefor Home key jumps.onSpacerAfterVisible): only whenanchorMode & 2 (End), or viarefreshObservedElementswhen items are appended while at bottom.Home/Endkey jumps always work regardless of mode — they clear any active suppression and start convergence immediately.stopConvergenceObservingnow takes a fresh anchor snapshot so subsequent anchor restores have valid data.ess of mode