Skip to content

Commit fcf0610

Browse files
authored
[Virtualization] Visible content does not shift when in-DOM items above the viewport change height (#65951)
* Viewport is anchored and DOM updates do not make it jump. * Remove offsetHeight pre-seed from anchoredItems to avoid phantom scroll compensation. * Native anchoring with fallback. * Prepended items over viewport do not move it if we operate in-memory. * Convergence should not fight with anchoring.
1 parent a6d4627 commit fcf0610

4 files changed

Lines changed: 481 additions & 16 deletions

File tree

src/Components/Web.JS/src/Virtualize.ts

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,28 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
4949
return;
5050
}
5151

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

5958
const rangeBetweenSpacers = document.createRange();
6059

61-
if (isValidTableElement(spacerAfter.parentElement)) {
60+
if (isTable) {
6261
spacerBefore.style.display = 'table-row';
6362
spacerAfter.style.display = 'table-row';
6463
}
6564

65+
if (useNativeAnchoring) {
66+
// Prevent spacers from being used as scroll anchors — only rendered items should anchor.
67+
spacerBefore.style.overflowAnchor = 'none';
68+
spacerAfter.style.overflowAnchor = 'none';
69+
} else {
70+
// Manual compensation path for tables and browsers without native anchoring.
71+
scrollElement.style.overflowAnchor = 'none';
72+
}
73+
6674
const intersectionObserver = new IntersectionObserver(intersectionCallback, {
6775
root: scrollContainer,
6876
rootMargin: `${rootMargin}px`,
@@ -74,10 +82,48 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
7482
let convergingElements = false;
7583
let convergenceItems: Set<Element> = new Set();
7684

77-
// ResizeObserver roles:
85+
const anchoredItems: Map<Element, number> = new Map();
86+
let scrollTriggeredRender = false;
87+
88+
function getObservedHeight(entry: ResizeObserverEntry): number {
89+
return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
90+
}
91+
92+
function compensateScrollForItemResizes(entries: ResizeObserverEntry[]): void {
93+
let scrollDelta = 0;
94+
const containerTop = scrollContainer
95+
? scrollContainer.getBoundingClientRect().top
96+
: 0;
97+
98+
for (const entry of entries) {
99+
if (entry.target === spacerBefore || entry.target === spacerAfter) {
100+
continue;
101+
}
102+
103+
if (entry.target.isConnected) {
104+
const el = entry.target as HTMLElement;
105+
const oldHeight = anchoredItems.get(el);
106+
const newHeight = getObservedHeight(entry);
107+
anchoredItems.set(el, newHeight);
108+
109+
if (oldHeight !== undefined && oldHeight !== newHeight) {
110+
if (el.getBoundingClientRect().top < containerTop) {
111+
scrollDelta += (newHeight - oldHeight);
112+
}
113+
}
114+
}
115+
}
116+
117+
if (scrollDelta !== 0 && scrollElement.scrollTop > 0) {
118+
scrollElement.scrollTop += scrollDelta;
119+
}
120+
}
121+
122+
// ResizeObserver roles:
78123
// 1. Always observes both spacers so that when a spacer resizes we re-trigger the
79124
// IntersectionObserver — which otherwise won't fire again for an element that is already visible.
80125
// 2. For convergence (sticky-top/bottom) - observes elements for geometry changes, drives the scroll position.
126+
// 3. Manual scroll compensation (tables/Safari) — adjusts scrollTop when above-viewport items resize.
81127
const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]): void => {
82128
for (const entry of entries) {
83129
if (entry.target === spacerBefore || entry.target === spacerAfter) {
@@ -100,20 +146,29 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
100146
} else if (convergingElements) {
101147
stopConvergenceObserving();
102148
}
149+
150+
// Manual scroll compensation: adjust scrollTop for above-viewport resizes.
151+
if (!useNativeAnchoring) {
152+
compensateScrollForItemResizes(entries);
153+
}
103154
});
104155

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

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

167+
if (useNativeAnchoring) {
168+
spacerBefore.style.overflowAnchor = 'none';
169+
spacerAfter.style.overflowAnchor = 'none';
170+
}
171+
117172
// Ensure spacers are always observed (idempotent).
118173
resizeObserver.observe(spacerBefore);
119174
resizeObserver.observe(spacerAfter);
@@ -132,15 +187,38 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
132187
}
133188
}
134189
convergenceItems = currentItems;
190+
return;
135191
}
136192

193+
// Manual compensation: observe items so ResizeObserver can compensate scrollTop.
194+
// Skip for native anchoring (browser handles it) and scroll-triggered renders
195+
// (avoids layout interference drift).
196+
if (!useNativeAnchoring && !scrollTriggeredRender) {
197+
const currentItems = new Set<Element>();
198+
for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) {
199+
resizeObserver.observe(el);
200+
currentItems.add(el);
201+
}
202+
203+
for (const [el] of anchoredItems) {
204+
if (!currentItems.has(el)) {
205+
resizeObserver.unobserve(el);
206+
anchoredItems.delete(el);
207+
}
208+
}
209+
}
210+
scrollTriggeredRender = false;
211+
137212
// Don't re-trigger IntersectionObserver here — ResizeObserver handles that
138213
// when spacers actually resize. Doing it on every render causes feedback loops.
139214
}
140215

141216
function startConvergenceObserving(): void {
142217
if (convergingElements) return;
143218
convergingElements = true;
219+
if (useNativeAnchoring) {
220+
scrollElement.style.overflowAnchor = 'none';
221+
}
144222
for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) {
145223
resizeObserver.observe(el);
146224
convergenceItems.add(el);
@@ -154,6 +232,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
154232
resizeObserver.unobserve(el);
155233
}
156234
convergenceItems.clear();
235+
if (useNativeAnchoring) {
236+
scrollElement.style.overflowAnchor = '';
237+
}
238+
anchoredItems.clear();
157239
}
158240

159241
let convergingToBottom = false;
@@ -168,9 +250,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
168250
if (ke.key === 'End') {
169251
pendingJumpToEnd = true;
170252
pendingJumpToStart = false;
253+
if (!convergingToBottom && spacerAfter.offsetHeight > 0) {
254+
convergingToBottom = true;
255+
startConvergenceObserving();
256+
}
171257
} else if (ke.key === 'Home') {
172258
pendingJumpToStart = true;
173259
pendingJumpToEnd = false;
260+
if (!convergingToTop && spacerBefore.offsetHeight > 0) {
261+
convergingToTop = true;
262+
startConvergenceObserving();
263+
}
174264
}
175265
}
176266
keydownTarget.addEventListener('keydown', handleJumpKeys);
@@ -185,8 +275,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
185275
refreshObservedElements,
186276
scrollElement,
187277
startConvergenceObserving,
278+
setConvergingToBottom: () => { convergingToBottom = true; },
188279
onDispose: () => {
189280
stopConvergenceObserving();
281+
anchoredItems.clear();
190282
resizeObserver.disconnect();
191283
keydownTarget.removeEventListener('keydown', handleJumpKeys);
192284
if (callbackTimeout) {
@@ -295,6 +387,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
295387
intersectingEntries.forEach((entry): void => {
296388
const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor;
297389

390+
// So that RefreshObservedElements can skip item observation (avoids layout interference drift).
391+
scrollTriggeredRender = true;
392+
298393
if (entry.target === spacerBefore) {
299394
const spacerSize = (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor;
300395
dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize);
@@ -322,6 +417,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void {
322417
const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper);
323418
const entry = observersByDotNetObjectId[id];
324419
if (entry) {
420+
entry.setConvergingToBottom?.();
325421
entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight;
326422
entry.startConvergenceObserving?.();
327423
}

src/Components/Web/src/Virtualization/Virtualize.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
4747

4848
private IEnumerable<TItem>? _loadedItems;
4949

50+
// For in-memory Items where objects have stable identity
51+
private TItem? _previousFirstLoadedItem;
52+
5053
private CancellationTokenSource? _refreshCts;
5154

5255
private bool _skipNextDistributionRefresh;
@@ -515,9 +518,34 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess)
515518
// Only apply result if the task was not canceled.
516519
if (!cancellationToken.IsCancellationRequested)
517520
{
521+
var previousItemCount = _itemCount;
522+
var countDelta = result.TotalItemCount - previousItemCount;
523+
524+
// Detect if items were prepended above the current viewport position.
525+
if (countDelta > 0 && _itemsBefore > 0 && _previousFirstLoadedItem != null
526+
&& _itemsProvider == DefaultItemsProvider)
527+
{
528+
var newFirstItem = Items!.ElementAtOrDefault(_itemsBefore);
529+
if (newFirstItem != null && !ReferenceEquals(_previousFirstLoadedItem, newFirstItem))
530+
{
531+
_itemsBefore = Math.Min(_itemsBefore + countDelta, Math.Max(0, result.TotalItemCount - _visibleItemCapacity));
532+
533+
var adjustedRequest = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken);
534+
result = await _itemsProvider(adjustedRequest);
535+
}
536+
}
537+
518538
_itemCount = result.TotalItemCount;
519539
_loadedItems = result.Items;
520-
_loadedItemsStartIndex = request.StartIndex;
540+
_loadedItemsStartIndex = _itemsBefore;
541+
542+
// Only needed for DefaultItemsProvider; custom providers return new instances
543+
// per request, making ReferenceEquals unreliable.
544+
_previousFirstLoadedItem = _itemsProvider == DefaultItemsProvider
545+
&& Items != null && _itemsBefore < Items.Count
546+
? Items.ElementAtOrDefault(_itemsBefore)
547+
: default;
548+
521549
_loading = false;
522550
_skipNextDistributionRefresh = request.Count > 0;
523551

0 commit comments

Comments
 (0)