Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 87 additions & 15 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const Virtualize = {
dispose,
scrollToBottom,
refreshObservers,
setAnchorMode,
};

const dispatcherObserversByDotNetIdPropname = Symbol();
Expand Down Expand Up @@ -42,15 +43,15 @@ function getScaleFactor(spacerBefore: HTMLElement, spacerAfter: HTMLElement): nu
return (Number.isFinite(scale) && scale > 0) ? scale : 1;
}

function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50): void {
function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50, anchorMode = 1): void {
// If the component was disposed before the JS interop call completed, the element references may be null
// or the elements may have been disconnected from the DOM. Return early to avoid errors.
if (!spacerBefore || !spacerAfter || !spacerBefore.isConnected || !spacerAfter.isConnected) {
return;
}

const scrollContainer = findClosestScrollContainer(spacerBefore);
const scrollElement = scrollContainer || document.documentElement;
const scrollElement = (scrollContainer || document.scrollingElement || document.documentElement) as HTMLElement;
const isTable = isValidTableElement(spacerAfter.parentElement);
const supportsAnchor = CSS.supports('overflow-anchor', 'auto');
const useNativeAnchoring = !isTable && supportsAnchor;
Expand Down Expand Up @@ -85,6 +86,31 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
const anchoredItems: Map<Element, number> = new Map();
let scrollTriggeredRender = false;

// None-mode top-prepend compensation is applied explicitly after render using the
// current item-size estimate. Ignore the next stale spacerBefore IO callback, and
// allow one follow-up measured correction if spacerBefore's actual height differs.
let skipNextSpacerBeforeCallback = false;
let pendingSpacerBeforeCompensationHeight: number | null = null;

function reobserveSpacerBefore(): void {
if (spacerBefore.isConnected) {
intersectionObserver.unobserve(spacerBefore);
intersectionObserver.observe(spacerBefore);
}
}

function applyPrependCompensation(expectedHeight: number): void {
if (expectedHeight <= 0) {
return;
}

scrollElement.scrollTop += expectedHeight;
pendingSpacerBeforeCompensationHeight = expectedHeight;
skipNextSpacerBeforeCallback = true;

queueMicrotask(reobserveSpacerBefore);
}

function getObservedHeight(entry: ResizeObserverEntry): number {
return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
}
Expand Down Expand Up @@ -125,6 +151,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
// 2. For convergence (sticky-top/bottom) - observes elements for geometry changes, drives the scroll position.
// 3. Manual scroll compensation (tables/Safari) — adjusts scrollTop when above-viewport items resize.
const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]): void => {
if (pendingSpacerBeforeCompensationHeight !== null) {
const remainingHeightDifference = spacerBefore.offsetHeight - pendingSpacerBeforeCompensationHeight;
pendingSpacerBeforeCompensationHeight = null;

if (remainingHeightDifference !== 0) {
scrollElement.scrollTop += remainingHeightDifference;
skipNextSpacerBeforeCallback = true;
queueMicrotask(reobserveSpacerBefore);
}
}

for (const entry of entries) {
if (entry.target === spacerBefore || entry.target === spacerAfter) {
const spacer = entry.target as HTMLElement;
Expand Down Expand Up @@ -157,7 +194,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
resizeObserver.observe(spacerBefore);
resizeObserver.observe(spacerAfter);

function refreshObservedElements(): void {
function refreshObservedElements(prependCompensation = 0): void {
// C# style updates overwrite the entire style attribute. Re-apply what we need.
if (isTable) {
spacerBefore.style.display = 'table-row';
Expand All @@ -173,6 +210,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
resizeObserver.observe(spacerBefore);
resizeObserver.observe(spacerAfter);

if (prependCompensation > 0) {
applyPrependCompensation(prependCompensation);
}

// During convergence, keep the observed element set in sync with the DOM.
if (convergingElements) {
const currentItems: Set<Element> = new Set();
Expand Down Expand Up @@ -276,6 +317,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
scrollElement,
startConvergenceObserving,
setConvergingToBottom: () => { convergingToBottom = true; },
setAnchorMode: (mode: number) => { anchorMode = mode; },
onDispose: () => {
stopConvergenceObserving();
anchoredItems.clear();
Expand Down Expand Up @@ -319,15 +361,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
}
if (convergingToBottom) return;

const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1;
if (!atBottom && !pendingJumpToEnd) return;

convergingToBottom = true;
startConvergenceObserving();
// pendingJumpToEnd is user-initiated (End key) — always honor it.
// Data-driven convergence only fires when End anchoring is enabled.
if (pendingJumpToEnd) {
convergingToBottom = true;
startConvergenceObserving();
scrollElement.scrollTop = scrollElement.scrollHeight;
pendingJumpToEnd = false;
return;
}

if (!(anchorMode & 2)) return;

const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1;
if (!atBottom) return;

convergingToBottom = true;
startConvergenceObserving();
}

function onSpacerBeforeVisible(): void {
Expand All @@ -340,15 +390,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
}
if (convergingToTop) return;

const atTop = scrollElement.scrollTop < 1;
if (!atTop && !pendingJumpToStart) return;

convergingToTop = true;
startConvergenceObserving();
// pendingJumpToStart is user-initiated (Home key) — always honor it.
// Data-driven convergence only fires when Beginning anchoring is enabled.
if (pendingJumpToStart) {
convergingToTop = true;
startConvergenceObserving();
scrollElement.scrollTop = 0;
pendingJumpToStart = false;
return;
}

if (!(anchorMode & 1)) return;

const atTop = scrollElement.scrollTop < 1;
if (!atTop) return;

convergingToTop = true;
startConvergenceObserving();
}

function processIntersectionEntries(entries: IntersectionObserverEntry[]): void {
Expand All @@ -358,6 +416,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
}

const intersectingEntries = entries.filter(entry => {
// During None-mode prepend compensation, skip the stale spacerBefore
// callback that was computed before scrollTop was explicitly adjusted.
if (skipNextSpacerBeforeCallback && entry.target === spacerBefore) {
skipNextSpacerBeforeCallback = false;
queueMicrotask(reobserveSpacerBefore);
return false;
}

if (entry.isIntersecting) {
if (entry.target === spacerAfter) {
onSpacerAfterVisible();
Expand Down Expand Up @@ -423,10 +489,16 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void {
}
}

function refreshObservers(dotNetHelper: DotNet.DotNetObject): void {
function refreshObservers(dotNetHelper: DotNet.DotNetObject, prependCompensation = 0): void {
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
const entry = observersByDotNetObjectId[id];
entry?.refreshObservedElements?.(prependCompensation);
}

function setAnchorMode(dotNetHelper: DotNet.DotNetObject, mode: number): void {
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
const entry = observersByDotNetObjectId[id];
entry?.refreshObservedElements?.();
entry?.setAnchorMode?.(mode);
}

function getObserversMapEntry(dotNetHelper: DotNet.DotNetObject): { observersByDotNetObjectId: {[id: number]: any }, id: number } {
Expand Down
6 changes: 6 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(byte[]! data,
Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void
Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string!
Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream!
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.AnchorMode.get -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<TItem>.AnchorMode.set -> void
Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.Beginning = 1 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.End = 2 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode.None = 0 -> Microsoft.AspNetCore.Components.Web.Virtualization.VirtualizeAnchorMode
virtual Microsoft.AspNetCore.Components.Forms.InputFile.Dispose(bool disposing) -> void
Microsoft.AspNetCore.Components.Forms.DisplayName<TValue>
Microsoft.AspNetCore.Components.Forms.DisplayName<TValue>.DisplayName() -> void
Expand Down
45 changes: 39 additions & 6 deletions src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I

internal bool _pendingScrollToBottom;

private float _pendingPrependCompensationPx;

private VirtualizeAnchorMode _lastRenderedAnchorMode;

[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;

Expand Down Expand Up @@ -150,6 +154,14 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
[Parameter]
public int MaxItemCount { get; set; } = 100;

/// <summary>
/// Gets or sets the anchor mode that controls how the viewport behaves at the edges
/// of the list when new items arrive. The default is <see cref="VirtualizeAnchorMode.Beginning"/>,
/// which preserves backward-compatible behavior.
/// </summary>
[Parameter]
public VirtualizeAnchorMode AnchorMode { get; set; } = VirtualizeAnchorMode.Beginning;

/// <summary>
/// Instructs the component to re-request data from its <see cref="ItemsProvider"/>.
/// This is useful if external data may have changed. There is no need to call this
Expand Down Expand Up @@ -230,7 +242,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
if (firstRender)
{
_jsInterop = new VirtualizeJsInterop(this, JSRuntime);
await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter);
await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter, (int)AnchorMode);
_lastRenderedAnchorMode = AnchorMode;
}

if (_pendingScrollToBottom && _jsInterop is not null)
Expand All @@ -242,7 +255,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
// After render the set of items could change. Tell JS to refresh ResizeObserver.
if (!firstRender && _jsInterop is not null)
{
await _jsInterop.RefreshObserversAsync();
if (_lastRenderedAnchorMode != AnchorMode)
{
_lastRenderedAnchorMode = AnchorMode;
await _jsInterop.SetAnchorModeAsync((int)AnchorMode);
}

var pendingPrependCompensationPx = _pendingPrependCompensationPx;
_pendingPrependCompensationPx = 0;
await _jsInterop.RefreshObserversAsync(pendingPrependCompensationPx);
}
}

Expand All @@ -260,7 +281,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenElement(0, SpacerElement);
builder.AddAttribute(1, "style", GetSpacerStyle(_itemsBefore));
builder.AddAttribute(2, "aria-hidden", "true");
builder.AddElementReferenceCapture(3, elementReference => _spacerBefore = elementReference);
builder.AddElementReferenceCapture(4, elementReference => _spacerBefore = elementReference);
builder.CloseElement();

var lastItemIndex = Math.Min(_itemsBefore + _visibleItemCapacity, _itemCount);
Expand Down Expand Up @@ -393,7 +414,8 @@ void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerS

// When we're at the very bottom and new measurements arrived,
// scroll to bottom so the viewport stays pinned while items converge.
if (itemsAfter == 0 && hadNewMeasurements)
// Only activate when AnchorMode includes the End flag.
if (itemsAfter == 0 && hadNewMeasurements && (AnchorMode & VirtualizeAnchorMode.End) != 0)
{
_pendingScrollToBottom = true;
}
Expand Down Expand Up @@ -522,13 +544,24 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess)
var countDelta = result.TotalItemCount - previousItemCount;

// Detect if items were prepended above the current viewport position.
if (countDelta > 0 && _itemsBefore > 0 && _previousFirstLoadedItem != null
if (countDelta > 0 && _previousFirstLoadedItem != null
&& _itemsProvider == DefaultItemsProvider)
{
var newFirstItem = Items!.ElementAtOrDefault(_itemsBefore);
if (newFirstItem != null && !ReferenceEquals(_previousFirstLoadedItem, newFirstItem))
{
_itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity));
if (_itemsBefore > 0)
{
// Mid-list: adjust itemsBefore to keep the same items visible.
_itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity));
}
else if (AnchorMode == VirtualizeAnchorMode.None)
{
// At the top edge in None mode, apply an explicit post-render
// scroll compensation using the current item-size estimate.
_itemsBefore = Math.Min(countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity));
_pendingPrependCompensationPx = countDelta * _itemSize;
}

var adjustedRequest = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken);
result = await _itemsProvider(adjustedRequest);
Expand Down
34 changes: 34 additions & 0 deletions src/Components/Web/src/Virtualization/VirtualizeAnchorMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Web.Virtualization;

/// <summary>
/// Controls how the viewport behaves at the edges of the list when
/// new items arrive. Flags can be combined to pin both edges.
/// </summary>
[Flags]
public enum VirtualizeAnchorMode
{
/// <summary>
/// No edge pinning. The viewport stays at its current scroll position
/// regardless of item changes.
/// </summary>
None = 0,

/// <summary>
/// Pins the viewport to the beginning of the list. When the user is
/// at or near the top and new items arrive at the beginning, the viewport
/// stays at the top showing the newest items — matching standard news
/// feed / notification list UX.
/// </summary>
Beginning = 1,

/// <summary>
/// Pins the viewport to the end of the list. When the user is at or near
/// the bottom and new items arrive at the end, the viewport auto-scrolls
/// to show them. If the user has scrolled away, auto-scroll disengages
/// until they return to the bottom — matching standard chat / log UX.
/// </summary>
End = 2,
}
13 changes: 9 additions & 4 deletions src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ public VirtualizeJsInterop(IVirtualizeJsCallbacks owner, IJSRuntime jsRuntime)
_jsRuntime = jsRuntime;
}

public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter)
public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter, int anchorMode)
{
_selfReference = DotNetObjectReference.Create(this);
await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter);
await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter, /* rootMargin */ 50, anchorMode);
}

[JSInvokable]
Expand All @@ -47,9 +47,14 @@ public ValueTask ScrollToBottomAsync()
return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.scrollToBottom", _selfReference);
}

public ValueTask RefreshObserversAsync()
public ValueTask RefreshObserversAsync(float prependCompensationPx = 0)
{
return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.refreshObservers", _selfReference);
return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.refreshObservers", _selfReference, prependCompensationPx);
}

public ValueTask SetAnchorModeAsync(int anchorMode)
{
return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.setAnchorMode", _selfReference, anchorMode);
}

public async ValueTask DisposeAsync()
Expand Down
Loading
Loading