Skip to content

Virtualization AnchorMode with for variable-height support#66262

Merged
ilonatommy merged 29 commits intodotnet:mainfrom
ilonatommy:anchor-modes-clean
Apr 29, 2026
Merged

Virtualization AnchorMode with for variable-height support#66262
ilonatommy merged 29 commits intodotnet:mainfrom
ilonatommy:anchor-modes-clean

Conversation

@ilonatommy
Copy link
Copy Markdown
Member

@ilonatommy ilonatommy commented Apr 10, 2026

Summary

Adds a new AnchorMode parameter 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

[Flags]
public enum VirtualizeAnchorMode
{
    None = 0,      // No edge pinning — viewport stays at current scroll position
    Beginning = 1, // Pins to top — new items at the beginning keep viewport at the top (news feed UX)
    End = 2,       // Pins to bottom — new items at the end auto-scroll to show them (chat/log UX)
}

Virtualize.AnchorMode - default is Beginning (backward-compatible with .NET 10 behavior).

Modes can be combined: Beginning | End pins both edges. Combining None with other modes is supported but does not change the combined value → since None = 0 the combination None | Beginning is just Beginning.

/// <summary>
/// Gets or sets a comparer used to detect whether items were prepended or appended
/// when using <see cref="ItemsProvider"/>. The comparer determines if the first loaded
/// item changed between provider calls, which indicates items were inserted above.
///
/// Defaults to <see cref="EqualityComparer{T}.Default"/>. For records and types implementing
/// <see cref="IEquatable{T}"/>, the default works automatically (value equality). For classes
/// without value-equality semantics, provide a comparer that compares by a unique identifier
/// (e.g., <c>Id</c>); otherwise reference-equality fallback would produce false-positive
/// prepend detection when the provider returns fresh instances.
///
/// Prepend detection only runs when this parameter is explicitly assigned by the consumer.
/// The <c>BL0011</c> analyzer warns when <see cref="ItemsProvider"/> is used without an
/// explicit <see cref="ItemComparer"/> assignment.
///
/// For in-memory <see cref="Items"/>, this parameter is not needed because the component
/// can detect prepends using object identity.
 /// </summary>
[Parameter]
public IEqualityComparer<TItem>? ItemComparer { get; set; }

Virtualize.ItemComparer — optional. Required for anchor stability when using ItemsProvider with class-typed items. Not needed for in-memory Items (uses ReferenceEquals on the first loaded item instead). For records, the default comparer's value-equality behavior works automatically.

A Roslyn analyzer (BL0011) warns at compile time when Virtualize is used with ItemsProvider but without ItemComparer, so users get a clear hint that anchor stability requires a comparer in that configuration.

Behavior matrix

Scenario None Beginning End
Prepend at top Viewport stable (scroll compensated) Stays at top showing new items Viewport stable
Append at bottom Viewport stable Viewport stable Follows to new bottom
Mid-list changes Viewport stable Viewport stable Viewport stable
Home/End keys Still works Works Works

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) and anchorOffset (viewport-relative pixel position) on every IntersectionObserver callback, every scroll event, 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:

  • In-memory Items: Compares the first loaded item via ReferenceEquals against the previously stored reference (_previousFirstLoadedItem). If the identity changed, a prepend is detected.
  • ItemsProvider + ItemKey: Calls ItemKey on the first returned item and compares with Equals against the previously stored key (_previousFirstLoadedItemKey). If the key changed, a prepend is detected.
  • ItemsProvider without ItemKey: 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) via AdjustForPrependAsync so the same items stay in the DOM at the same child positions, and sets _pendingAnchorRestore = true to signal JS.

For appends near the bottom in non-End modes, ShouldAnchorForAppend sets _pendingAnchorRestore = true to 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 _pendingAnchorRestore is set, C# calls RestoreAnchorAsync() (no parameters — JS reads the snapshot directly). JS then decides what to do based on anchor mode and scroll position:

  • Beginning mode + at top (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.
  • All other cases: Finds the anchor item by walking the DOM from spacerBefore to the anchorItemIndex-th child, measures the pixel delta between its current and saved position, and adjusts scrollTop by the exact delta. Also saves drift correction data (scrollCorrectionItemIndex + offset) so refreshObservedElements can correct any residual drift after spacer→item redistribution.

Example — prepend with 3 items:

  • Before render: Item at DOM child #5 is at 120px from the container top (snapshot saved).
  • Render happens: _itemsBefore adjusted so the same items stay at the same child positions, but spacerBefore grows by ~150px, pushing everything down.
  • Restore: JS finds child #5 at 270px, computes delta = 270 - 120 = 150px, adjusts scrollTop += 150.
  • Result: the user sees no visual jump — the same content stays in view despite the DOM changing above it.

4. Stale callback suppression

After adjusting scrollTop, all spacer IntersectionObserver callbacks are suppressed (suppressSpacerCallbacks = true) until the next user-initiated scroll or Home/End key press. Without this, the IO callback would recalculate _itemsBefore with pre-adjustment intersection data and undo the scroll compensation.

Additionally, the ignoreAnchorScroll flag is set so the scroll event triggered by the scrollTop adjustment 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. refreshObservedElements checks 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:

  • Top convergence: triggered explicitly in restoreAnchorForShift when Beginning mode is active and scrollTop < 1, or via onSpacerBeforeVisible for Home key jumps.
  • Bottom convergence (onSpacerAfterVisible): only when anchorMode & 2 (End), or via refreshObservedElements when items are appended while at bottom.
  • Home/End key jumps always work regardless of mode — they clear any active suppression and start convergence immediately.
  • stopConvergenceObserving now takes a fresh anchor snapshot so subsequent anchor restores have valid data.
    ess of mode

Copilot AI review requested due to automatic review settings April 10, 2026 09:07
@ilonatommy ilonatommy added area-blazor Includes: Blazor, Razor Components feature-blazor-virtualization This issue is related to the Blazor Virtualize component labels Apr 10, 2026
@ilonatommy ilonatommy requested a review from a team as a code owner April 10, 2026 09:07
@ilonatommy ilonatommy self-assigned this Apr 10, 2026
@ilonatommy ilonatommy added this to the 10.0.4 milestone Apr 10, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 VirtualizeAnchorMode and the Virtualize<TItem>.AnchorMode parameter, 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.

Comment thread src/Components/Web/src/Virtualization/Virtualize.cs Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/test/E2ETest/Tests/VirtualizationTest.cs Outdated
Comment thread src/Components/test/E2ETest/Tests/VirtualizationTest.cs Outdated
Comment thread src/Components/test/E2ETest/Tests/VirtualizationTest.cs Outdated
…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>
@ilonatommy ilonatommy force-pushed the anchor-modes-clean branch 3 times, most recently from fd1070e to 703a710 Compare April 14, 2026 13:19
…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>
@dariatiurina dariatiurina dismissed their stale review April 24, 2026 14:30

Missclick

…m the edge + remove drift correction because it was causing sub-pixel shifts.
Copy link
Copy Markdown
Contributor

@dariatiurina dariatiurina left a comment

Choose a reason for hiding this comment

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

Thank you for your work! A few notes, but otherwise, looks good to me.

Comment thread src/Components/Analyzers/src/Resources.resx Outdated
Comment thread src/Components/Analyzers/src/VirtualizeItemComparerAnalyzer.cs Outdated
Comment thread src/Components/Web/src/Virtualization/Virtualize.cs Outdated
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.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Follow-up.

@ilonatommy ilonatommy enabled auto-merge (squash) April 28, 2026 18:26
@ilonatommy
Copy link
Copy Markdown
Member Author

/backport to release/11.0-preview4

@github-actions
Copy link
Copy Markdown
Contributor

Started backporting to release/11.0-preview4 (link to workflow run)

@github-actions
Copy link
Copy Markdown
Contributor

@ilonatommy backporting to release/11.0-preview4 failed, the patch most likely resulted in conflicts. Please backport manually!

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

Link to workflow output

@ilonatommy
Copy link
Copy Markdown
Member Author

/ba-g template test failures unrelated - we don't have Virtualization in templates and this PR didn't touch that part

@ilonatommy ilonatommy merged commit ac52faa into dotnet:main Apr 29, 2026
24 of 25 checks passed
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.

Virtualization supports anchor modes

6 participants