Skip to content
Merged
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
114 changes: 105 additions & 9 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,28 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
return;
}

// Overflow anchoring can cause an ongoing scroll loop, because when we resize the spacers, the browser
// would update the scroll position to compensate. Then the spacer would remain visible and we'd keep on
// trying to resize it.
const scrollContainer = findClosestScrollContainer(spacerBefore);
const scrollElement = scrollContainer || document.documentElement;
scrollElement.style.overflowAnchor = 'none';
const isTable = isValidTableElement(spacerAfter.parentElement);
const supportsAnchor = CSS.supports('overflow-anchor', 'auto');
const useNativeAnchoring = !isTable && supportsAnchor;

const rangeBetweenSpacers = document.createRange();

if (isValidTableElement(spacerAfter.parentElement)) {
if (isTable) {
spacerBefore.style.display = 'table-row';
spacerAfter.style.display = 'table-row';
}

if (useNativeAnchoring) {
// Prevent spacers from being used as scroll anchors — only rendered items should anchor.
spacerBefore.style.overflowAnchor = 'none';
spacerAfter.style.overflowAnchor = 'none';
} else {
// Manual compensation path for tables and browsers without native anchoring.
scrollElement.style.overflowAnchor = 'none';
}

const intersectionObserver = new IntersectionObserver(intersectionCallback, {
root: scrollContainer,
rootMargin: `${rootMargin}px`,
Expand All @@ -74,10 +82,48 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
let convergingElements = false;
let convergenceItems: Set<Element> = new Set();

// ResizeObserver roles:
const anchoredItems: Map<Element, number> = new Map();
let scrollTriggeredRender = false;

function getObservedHeight(entry: ResizeObserverEntry): number {
return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
Comment thread
ilonatommy marked this conversation as resolved.
}

function compensateScrollForItemResizes(entries: ResizeObserverEntry[]): void {
let scrollDelta = 0;
const containerTop = scrollContainer
? scrollContainer.getBoundingClientRect().top
: 0;

for (const entry of entries) {
if (entry.target === spacerBefore || entry.target === spacerAfter) {
continue;
}

if (entry.target.isConnected) {
const el = entry.target as HTMLElement;
const oldHeight = anchoredItems.get(el);
const newHeight = getObservedHeight(entry);
anchoredItems.set(el, newHeight);

if (oldHeight !== undefined && oldHeight !== newHeight) {
if (el.getBoundingClientRect().top < containerTop) {
scrollDelta += (newHeight - oldHeight);
}
}
}
}

if (scrollDelta !== 0 && scrollElement.scrollTop > 0) {
scrollElement.scrollTop += scrollDelta;
}
}

// ResizeObserver roles:
// 1. Always observes both spacers so that when a spacer resizes we re-trigger the
// IntersectionObserver — which otherwise won't fire again for an element that is already visible.
// 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 => {
for (const entry of entries) {
if (entry.target === spacerBefore || entry.target === spacerAfter) {
Expand All @@ -100,20 +146,29 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
} else if (convergingElements) {
stopConvergenceObserving();
}

// Manual scroll compensation: adjust scrollTop for above-viewport resizes.
if (!useNativeAnchoring) {
compensateScrollForItemResizes(entries);
}
});

// Always observe both spacers for the IntersectionObserver re-trigger.
resizeObserver.observe(spacerBefore);
resizeObserver.observe(spacerAfter);

function refreshObservedElements(): void {
// C# style updates overwrite the entire style attribute, losing display: table-row.
// Re-apply it so spacers participate in table layout alongside bare <tr> items.
if (isValidTableElement(spacerAfter.parentElement)) {
// C# style updates overwrite the entire style attribute. Re-apply what we need.
if (isTable) {
spacerBefore.style.display = 'table-row';
spacerAfter.style.display = 'table-row';
}

if (useNativeAnchoring) {
spacerBefore.style.overflowAnchor = 'none';
spacerAfter.style.overflowAnchor = 'none';
}

// Ensure spacers are always observed (idempotent).
resizeObserver.observe(spacerBefore);
resizeObserver.observe(spacerAfter);
Expand All @@ -132,15 +187,38 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
}
}
convergenceItems = currentItems;
return;
}

// Manual compensation: observe items so ResizeObserver can compensate scrollTop.
// Skip for native anchoring (browser handles it) and scroll-triggered renders
// (avoids layout interference drift).
if (!useNativeAnchoring && !scrollTriggeredRender) {
const currentItems = new Set<Element>();
for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) {
resizeObserver.observe(el);
currentItems.add(el);
}

for (const [el] of anchoredItems) {
if (!currentItems.has(el)) {
resizeObserver.unobserve(el);
anchoredItems.delete(el);
}
}
}
scrollTriggeredRender = false;

// Don't re-trigger IntersectionObserver here — ResizeObserver handles that
// when spacers actually resize. Doing it on every render causes feedback loops.
}

function startConvergenceObserving(): void {
if (convergingElements) return;
convergingElements = true;
if (useNativeAnchoring) {
scrollElement.style.overflowAnchor = 'none';
}
for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) {
resizeObserver.observe(el);
convergenceItems.add(el);
Expand All @@ -154,6 +232,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
resizeObserver.unobserve(el);
}
convergenceItems.clear();
if (useNativeAnchoring) {
scrollElement.style.overflowAnchor = '';
}
anchoredItems.clear();
}

let convergingToBottom = false;
Expand All @@ -168,9 +250,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
if (ke.key === 'End') {
pendingJumpToEnd = true;
pendingJumpToStart = false;
if (!convergingToBottom && spacerAfter.offsetHeight > 0) {
convergingToBottom = true;
startConvergenceObserving();
}
} else if (ke.key === 'Home') {
pendingJumpToStart = true;
pendingJumpToEnd = false;
if (!convergingToTop && spacerBefore.offsetHeight > 0) {
convergingToTop = true;
startConvergenceObserving();
}
}
}
keydownTarget.addEventListener('keydown', handleJumpKeys);
Expand All @@ -185,8 +275,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
refreshObservedElements,
scrollElement,
startConvergenceObserving,
setConvergingToBottom: () => { convergingToBottom = true; },
onDispose: () => {
stopConvergenceObserving();
anchoredItems.clear();
resizeObserver.disconnect();
keydownTarget.removeEventListener('keydown', handleJumpKeys);
if (callbackTimeout) {
Expand Down Expand Up @@ -295,6 +387,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
intersectingEntries.forEach((entry): void => {
const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor;

// So that RefreshObservedElements can skip item observation (avoids layout interference drift).
scrollTriggeredRender = true;

if (entry.target === spacerBefore) {
const spacerSize = (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor;
dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize);
Expand Down Expand Up @@ -322,6 +417,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void {
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
const entry = observersByDotNetObjectId[id];
if (entry) {
entry.setConvergingToBottom?.();
entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight;
entry.startConvergenceObserving?.();
}
Expand Down
30 changes: 29 additions & 1 deletion src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I

private IEnumerable<TItem>? _loadedItems;

// For in-memory Items where objects have stable identity
private TItem? _previousFirstLoadedItem;

private CancellationTokenSource? _refreshCts;

private bool _skipNextDistributionRefresh;
Expand Down Expand Up @@ -515,9 +518,34 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess)
// Only apply result if the task was not canceled.
if (!cancellationToken.IsCancellationRequested)
{
var previousItemCount = _itemCount;
var countDelta = result.TotalItemCount - previousItemCount;

// Detect if items were prepended above the current viewport position.
if (countDelta > 0 && _itemsBefore > 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));

var adjustedRequest = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken);
result = await _itemsProvider(adjustedRequest);
}
}

_itemCount = result.TotalItemCount;
_loadedItems = result.Items;
_loadedItemsStartIndex = request.StartIndex;
_loadedItemsStartIndex = _itemsBefore;

// Only needed for DefaultItemsProvider; custom providers return new instances
// per request, making ReferenceEquals unreliable.
_previousFirstLoadedItem = _itemsProvider == DefaultItemsProvider
&& Items != null && _itemsBefore < Items.Count
? Items.ElementAtOrDefault(_itemsBefore)
: default;

_loading = false;
_skipNextDistributionRefresh = request.Count > 0;

Expand Down
Loading
Loading