From 08276935473270a3056c4b45fe4813edf8b519c2 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 24 Mar 2026 16:38:48 +0100 Subject: [PATCH 01/16] Viewport is anchored and DOM updates do not make it jump. --- src/Components/Web.JS/src/Virtualize.ts | 104 ++++++++++-------- .../test/E2ETest/Tests/VirtualizationTest.cs | 93 ++++++++++++++++ .../VirtualizationDynamicContent.razor | 4 +- 3 files changed, 154 insertions(+), 47 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index dfee502d5766..598a626c7740 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -104,14 +104,19 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectionObserver.observe(spacerBefore); intersectionObserver.observe(spacerAfter); - let convergingElements = false; - let convergenceItems: Set = new Set(); + const anchoredItems: Map = new Map(); - // ResizeObserver roles: + function getObservedHeight(entry: ResizeObserverEntry): number { + return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; + } + + // 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. + // 2. For convergence (sticky-top/bottom) - drives the scroll position to top/bottom. + // 3. Viewport anchoring - compensates scroll position when content above the viewport resizes. const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]): void => { + // 1. Re-trigger IntersectionObserver for spacer resizes. for (const entry of entries) { if (entry.target === spacerBefore || entry.target === spacerAfter) { const spacer = entry.target as HTMLElement; @@ -122,16 +127,46 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - // Convergence logic: keep scroll pinned to top/bottom while items load. + // 2. Convergence: pin scroll to top/bottom while items load. if (convergingToBottom || convergingToTop) { scrollElement.scrollTop = convergingToBottom ? scrollElement.scrollHeight : 0; const spacer = convergingToBottom ? spacerAfter : spacerBefore; if (spacer.offsetHeight === 0) { convergingToBottom = convergingToTop = false; - stopConvergenceObserving(); } - } else if (convergingElements) { - stopConvergenceObserving(); + return; // Skip scroll compensation during convergence. + } + + // 3. Viewport anchoring: compensate scroll for above-viewport item changes. + let scrollDelta = 0; + const containerTop = scrollContainer + ? scrollContainer.getBoundingClientRect().top + : 0; + + for (const entry of entries) { + if (entry.target === spacerBefore || entry.target === spacerAfter) { + // Skip spacer entries — spacers resize during normal scroll-driven + // rendering. Compensating here would undo normal scrolling. + 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) { + // Compensate only if the element is entirely above the viewport. + if (el.getBoundingClientRect().bottom <= containerTop) { + scrollDelta += (newHeight - oldHeight); + } + } + } + } + + if (scrollDelta !== 0 && scrollElement.scrollTop > 0) { + scrollElement.scrollTop += scrollDelta; } }); @@ -151,44 +186,29 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); - // During convergence, keep the observed element set in sync with the DOM. - if (convergingElements) { - const currentItems: Set = new Set(); - for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { - resizeObserver.observe(el); - currentItems.add(el); + // Always observe all rendered items for viewport anchoring. When an item + // resizes above the viewport, the ResizeObserver callback compensates scrollTop. + const currentItems = new Set(); + for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { + resizeObserver.observe(el); + currentItems.add(el); + if (!anchoredItems.has(el)) { + anchoredItems.set(el, (el as HTMLElement).offsetHeight); } - // Unobserve items removed during re-render. - for (const el of convergenceItems) { - if (!currentItems.has(el)) { - resizeObserver.unobserve(el); - } + } + + // Unobserve items removed during re-render and clean up height tracking. + for (const [el] of anchoredItems) { + if (!currentItems.has(el)) { + resizeObserver.unobserve(el); + anchoredItems.delete(el); } - convergenceItems = currentItems; } // 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; - for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { - resizeObserver.observe(el); - convergenceItems.add(el); - } - } - - function stopConvergenceObserving(): void { - if (!convergingElements) return; - convergingElements = false; - for (const el of convergenceItems) { - resizeObserver.unobserve(el); - } - convergenceItems.clear(); - } - let convergingToBottom = false; let convergingToTop = false; @@ -217,9 +237,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver, refreshObservedElements, scrollElement, - startConvergenceObserving, onDispose: () => { - stopConvergenceObserving(); + anchoredItems.clear(); resizeObserver.disconnect(); keydownTarget.removeEventListener('keydown', handleJumpKeys); if (callbackTimeout) { @@ -254,7 +273,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (spacerAfter.offsetHeight === 0) { if (convergingToBottom) { convergingToBottom = false; - stopConvergenceObserving(); } return; } @@ -264,7 +282,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (!atBottom && !pendingJumpToEnd) return; convergingToBottom = true; - startConvergenceObserving(); if (pendingJumpToEnd) { scrollElement.scrollTop = scrollElement.scrollHeight; pendingJumpToEnd = false; @@ -275,7 +292,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (spacerBefore.offsetHeight === 0) { if (convergingToTop) { convergingToTop = false; - stopConvergenceObserving(); } return; } @@ -285,7 +301,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (!atTop && !pendingJumpToStart) return; convergingToTop = true; - startConvergenceObserving(); if (pendingJumpToStart) { scrollElement.scrollTop = 0; pendingJumpToStart = false; @@ -356,7 +371,6 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const entry = observersByDotNetObjectId[id]; if (entry) { entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; - entry.startConvergenceObserving?.(); } } diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 44220f466051..998ba21ef30c 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1844,4 +1844,97 @@ public void VirtualizeWorksInsideHorizontalOverflowContainer() var lastElement = Browser.Exists(By.Id("horizontal-overflow-row-999")); Browser.True(() => lastElement.Displayed); } + + [Fact] + public void ViewportAnchoring_ExpandAboveViewport_VisibleItemStaysInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll down so item 5 is above the viewport but still in DOM an verify its position + js.ExecuteScript("arguments[0].scrollTop = 500", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 500); + Browser.True(() => container.FindElements(By.CssSelector("[data-index='5']")).Count > 0); + + // Record the first visible item and its position relative to the container. + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + var scrollTopBefore = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("expand-item-5")).Click(); + Browser.Contains("Item 5 expanded via button", () => Browser.Exists(By.Id("status")).Text); + + // Wait for the expanded the 5th item content to appear in DOM + Browser.True(() => container.FindElements(By.CssSelector("[data-index='5'] .expanded-content")).Count > 0); + + var (_, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item", indexBefore); + + Assert.True(Math.Abs(relTopAfter - relTopBefore) < 5, + $"Visible item '{indexBefore}' should not have moved when off-screen item expanded. " + + $"RelTop Before: {relTopBefore:F1}, After: {relTopAfter:F1}, Delta: {relTopAfter - relTopBefore:F1}px. " + + $"scrollTop: {scrollTopBefore}->{scrollTopAfter}."); + } + + [Fact] + public void ViewportAnchoring_CollapseAboveViewport_VisibleItemStaysInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Expand item 5 while it's visible (at the top) + Browser.Exists(By.Id("expand-item-5")).Click(); + Browser.Contains("Item 5 expanded via button", () => Browser.Exists(By.Id("status")).Text); + + // Scroll down past item 5 so it's in overscan above viewport + js.ExecuteScript("arguments[0].scrollTop = 600", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 600); + + // Record first visible item position relative to container + var (indexBefore, relTopBefore, _) = GetItemPositionInContainer(js, container, ".item"); + + Browser.Exists(By.Id("collapse-all")).Click(); + Browser.Contains("All items collapsed", () => Browser.Exists(By.Id("status")).Text); + + var (_, relTopAfter, _) = GetItemPositionInContainer(js, container, ".item", indexBefore); + + Assert.True(Math.Abs(relTopAfter - relTopBefore) < 5, + $"Visible item '{indexBefore}' should not have moved when off-screen item collapsed. " + + $"RelTop Before: {relTopBefore:F1}, After: {relTopAfter:F1}, Delta: {relTopAfter - relTopBefore:F1}px"); + } + + private static (string index, double relTop, long scrollTop) GetItemPositionInContainer( + IJavaScriptExecutor js, IWebElement container, string itemSelector, string dataIndex = null) + { + var result = js.ExecuteScript(@" + var container = arguments[0]; + var selector = arguments[1]; + var targetIndex = arguments[2]; + var containerRect = container.getBoundingClientRect(); + var items = container.querySelectorAll(selector); + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var itemRect = item.getBoundingClientRect(); + if (targetIndex != null) { + if (item.getAttribute('data-index') === targetIndex) { + return { index: targetIndex, relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; + } + } else if (itemRect.top >= containerRect.top - 1 && itemRect.top < containerRect.bottom) { + return { index: item.getAttribute('data-index'), relTop: itemRect.top - containerRect.top, scrollTop: container.scrollTop }; + } + } + return null; + ", container, itemSelector, dataIndex) as Dictionary; + + Assert.NotNull(result); + return ( + result["index"].ToString(), + Convert.ToDouble(result["relTop"], CultureInfo.InvariantCulture), + Convert.ToInt64(result["scrollTop"], CultureInfo.InvariantCulture)); + } } diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor index 1c8a128d6f6a..7188b11abcc9 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor @@ -35,8 +35,8 @@ protected override void OnInitialized() { - // Create 30 items with initial height of 50px - items = Enumerable.Range(0, 30) + // 75 items with initial height of 50px (enough for true off-screen virtualization) + items = Enumerable.Range(0, 75) .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) .ToList(); } From b0b224cfd3fcf3bacfbe2450d49aa135a5753c1d Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 24 Mar 2026 17:01:22 +0100 Subject: [PATCH 02/16] FIx compensation close to the viewport boundary. --- src/Components/Web.JS/src/Virtualize.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 598a626c7740..1b93ff99d5f8 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -157,8 +157,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac anchoredItems.set(el, newHeight); if (oldHeight !== undefined && oldHeight !== newHeight) { - // Compensate only if the element is entirely above the viewport. - if (el.getBoundingClientRect().bottom <= containerTop) { + // Compensate if the element starts above the viewport (fully or partially above). + if (el.getBoundingClientRect().top < containerTop) { scrollDelta += (newHeight - oldHeight); } } From 38be1d9bd787bdd1467482cad3b87a3846554359 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 25 Mar 2026 09:52:30 +0100 Subject: [PATCH 03/16] Fix gramma. --- src/Components/test/E2ETest/Tests/VirtualizationTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 1e8964df7489..f29831f74bb1 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1671,7 +1671,7 @@ public void ViewportAnchoring_ExpandAboveViewport_VisibleItemStaysInPlace() var js = (IJavaScriptExecutor)Browser; Browser.True(() => GetElementCount(container, ".item") > 0); - // Scroll down so item 5 is above the viewport but still in DOM an verify its position + // Scroll down so item 5 is above the viewport but still in DOM and verify its position js.ExecuteScript("arguments[0].scrollTop = 500", container); Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 500); Browser.True(() => container.FindElements(By.CssSelector("[data-index='5']")).Count > 0); @@ -1684,7 +1684,7 @@ public void ViewportAnchoring_ExpandAboveViewport_VisibleItemStaysInPlace() Browser.Exists(By.Id("expand-item-5")).Click(); Browser.Contains("Item 5 expanded via button", () => Browser.Exists(By.Id("status")).Text); - // Wait for the expanded the 5th item content to appear in DOM + // Wait for the expanded content for item 5 to appear in the DOM Browser.True(() => container.FindElements(By.CssSelector("[data-index='5'] .expanded-content")).Count > 0); var (_, relTopAfter, scrollTopAfter) = GetItemPositionInContainer(js, container, ".item", indexBefore); From f9b4ce8a15367ce0be3d946be9b71d738d717ce4 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 25 Mar 2026 10:29:41 +0100 Subject: [PATCH 04/16] Remove offsetHeight pre-seed from anchoredItems to avoid phantom scroll compensation. --- src/Components/Web.JS/src/Virtualize.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 94922a9a7777..d1f441aa6191 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -159,9 +159,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { resizeObserver.observe(el); currentItems.add(el); - if (!anchoredItems.has(el)) { - anchoredItems.set(el, (el as HTMLElement).offsetHeight); - } } // Unobserve items removed during re-render and clean up height tracking. From 44fc396cc886643e3a3ab159b90f794c4783b18a Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 26 Mar 2026 12:53:21 +0100 Subject: [PATCH 05/16] Native anchoring with fallback. --- src/Components/Web.JS/src/Virtualize.ts | 150 ++++++++---------------- 1 file changed, 50 insertions(+), 100 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index d1f441aa6191..c34f009576d2 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -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) { + // Applied to rendered items - keeps viewport stable when spacer heights change. + 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`, @@ -73,6 +81,12 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const anchoredItems: Map = new Map(); + // Track whether the current render was triggered by scrolling (IntersectionObserver + // → C# recalculates → render). During scroll-triggered renders, observing table items + // with ResizeObserver causes layout interference that creates scroll drift. We only + // observe items during data-triggered renders (dynamic content changes). + let scrollTriggeredRender = false; + function getObservedHeight(entry: ResizeObserverEntry): number { return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; } @@ -80,8 +94,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // 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) - drives the scroll position to top/bottom. - // 3. Viewport anchoring - compensates scroll position when content above the viewport resizes. + // 2. Viewport anchoring — compensates scroll position when content above the viewport resizes. + // When native anchoring is available (non-table + supported browser), the browser handles this. + // Otherwise (tables, Safari), we use manual compensation via item height tracking. const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]): void => { // 1. Re-trigger IntersectionObserver for spacer resizes. for (const entry of entries) { @@ -94,17 +109,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - // 2. Convergence: pin scroll to top/bottom while items load. - if (convergingToBottom || convergingToTop) { - scrollElement.scrollTop = convergingToBottom ? scrollElement.scrollHeight : 0; - const spacer = convergingToBottom ? spacerAfter : spacerBefore; - if (spacer.offsetHeight === 0) { - convergingToBottom = convergingToTop = false; - } - return; // Skip scroll compensation during convergence. - } - - // 3. Viewport anchoring: compensate scroll for above-viewport item changes. + // 2. Viewport anchoring: compensate scroll for above-viewport item resizes. let scrollDelta = 0; const containerTop = scrollContainer ? scrollContainer.getBoundingClientRect().top @@ -124,7 +129,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac anchoredItems.set(el, newHeight); if (oldHeight !== undefined && oldHeight !== newHeight) { - // Compensate if the element starts above the viewport (fully or partially above). + // Compensate if the element is above the viewport (fully or partially). if (el.getBoundingClientRect().top < containerTop) { scrollDelta += (newHeight - oldHeight); } @@ -142,18 +147,35 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac 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 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) { + // Re-apply overflow-anchor: none on spacers after C# re-renders. + spacerBefore.style.overflowAnchor = 'none'; + spacerAfter.style.overflowAnchor = 'none'; + } + // Ensure spacers are always observed (idempotent). resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); - // Always observe all rendered items for viewport anchoring. When an item + // Item observation for manual scroll compensation: + // - When using native anchoring: browser handles above-viewport resizes + // automatically. Observing items here would cause double compensation. + // - When using manual compensation (tables + unsupported browsers): observe items + // so the ResizeObserver can compensate scrollTop. But only during data-triggered + // renders — observing during scroll-triggered renders causes layout interference. + if (useNativeAnchoring || scrollTriggeredRender) { + scrollTriggeredRender = false; + return; + } + scrollTriggeredRender = false; + + // Observe all rendered items for viewport anchoring. When an item // resizes above the viewport, the ResizeObserver callback compensates scrollTop. const currentItems = new Set(); for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { @@ -168,29 +190,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac anchoredItems.delete(el); } } - - // Don't re-trigger IntersectionObserver here — ResizeObserver handles that - // when spacers actually resize. Doing it on every render causes feedback loops. - } - - let convergingToBottom = false; - let convergingToTop = false; - - let pendingJumpToEnd = false; - let pendingJumpToStart = false; - - const keydownTarget: EventTarget = scrollContainer || document; - function handleJumpKeys(e: Event): void { - const ke = e as KeyboardEvent; - if (ke.key === 'End') { - pendingJumpToEnd = true; - pendingJumpToStart = false; - } else if (ke.key === 'Home') { - pendingJumpToStart = true; - pendingJumpToEnd = false; - } } - keydownTarget.addEventListener('keydown', handleJumpKeys); const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); let pendingCallbacks: Map = new Map(); @@ -204,7 +204,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac onDispose: () => { anchoredItems.clear(); resizeObserver.disconnect(); - keydownTarget.removeEventListener('keydown', handleJumpKeys); if (callbackTimeout) { clearTimeout(callbackTimeout); callbackTimeout = null; @@ -233,66 +232,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - function onSpacerAfterVisible(): void { - if (spacerAfter.offsetHeight === 0) { - if (convergingToBottom) { - convergingToBottom = false; - } - return; - } - if (convergingToBottom) return; - - const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; - if (!atBottom && !pendingJumpToEnd) return; - - convergingToBottom = true; - if (pendingJumpToEnd) { - scrollElement.scrollTop = scrollElement.scrollHeight; - pendingJumpToEnd = false; - } - } - - function onSpacerBeforeVisible(): void { - if (spacerBefore.offsetHeight === 0) { - if (convergingToTop) { - convergingToTop = false; - } - return; - } - if (convergingToTop) return; - - const atTop = scrollElement.scrollTop < 1; - if (!atTop && !pendingJumpToStart) return; - - convergingToTop = true; - if (pendingJumpToStart) { - scrollElement.scrollTop = 0; - pendingJumpToStart = false; - } - } - function processIntersectionEntries(entries: IntersectionObserverEntry[]): void { // Check if the spacers are still in the DOM. They may have been removed if the component was disposed. if (!spacerBefore.isConnected || !spacerAfter.isConnected) { return; } - const intersectingEntries = entries.filter(entry => { - if (entry.isIntersecting) { - if (entry.target === spacerAfter) { - onSpacerAfterVisible(); - } else if (entry.target === spacerBefore) { - onSpacerBeforeVisible(); - } - return true; - } - if (entry.target === spacerAfter && convergingToBottom && spacerAfter.offsetHeight > 0) { - scrollElement.scrollTop = scrollElement.scrollHeight; - } else if (entry.target === spacerBefore && convergingToTop && spacerBefore.offsetHeight > 0) { - scrollElement.scrollTop = 0; - } - return false; - }); + const intersectingEntries = entries.filter(entry => entry.isIntersecting); if (intersectingEntries.length === 0) { return; @@ -307,6 +253,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectingEntries.forEach((entry): void => { const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor; + // Mark the upcoming render as scroll-triggered so refreshObservedElements + // skips item observation for tables (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); From 7907cc8d61ccf4462812d9303bea0c7c4ec2e99e Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 26 Mar 2026 15:05:25 +0100 Subject: [PATCH 06/16] Tests. --- .../test/E2ETest/Tests/VirtualizationTest.cs | 112 +++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index f29831f74bb1..e0a5a5bd79d9 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -39,7 +39,7 @@ public void AlwaysFillsVisibleCapacity_Sync() { Browser.MountTestComponent(); var topSpacer = Browser.Exists(By.Id("sync-container")).FindElement(By.TagName("div")); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; int initialItemCount = 0; @@ -202,7 +202,7 @@ public void CancelsOutdatedRefreshes_Async() public void CanUseViewportAsContainer() { Browser.MountTestComponent(); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; var topSpacer = Browser.Exists(By.Id("viewport-as-root")).FindElement(By.TagName("div")); Browser.ExecuteJavaScript("const element = document.getElementById('viewport-as-root'); element.scrollIntoView();"); @@ -226,7 +226,7 @@ public async Task ToleratesIncorrectItemSize() { Browser.MountTestComponent(); var topSpacer = Browser.Exists(By.Id("incorrect-size-container")).FindElement(By.TagName("div")); - var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0;"; + var expectedInitialSpacerStyle = "height: 0px; flex-shrink: 0; overflow-anchor: none;"; // Wait until items have been rendered. Browser.True(() => GetItemCount() > 0); @@ -1725,6 +1725,39 @@ public void ViewportAnchoring_CollapseAboveViewport_VisibleItemStaysInPlace() $"RelTop Before: {relTopBefore:F1}, After: {relTopAfter:F1}, Delta: {relTopAfter - relTopBefore:F1}px"); } + [Fact] + public void Table_VariableHeight_IncrementalScrollDoesNotCauseWildJumps() + { + // Guards against a browser-level CSS Scroll Anchoring bug with layout. + // When overflow-anchor is allowed on elements, the anchoring algorithm + // miscalculates row positions causing 3000-8000px jumps per 300px scroll. + // The fix disables browser anchoring for tables and uses manual JS compensation. + Browser.MountTestComponent(); + + var js = (IJavaScriptExecutor)Browser; + Browser.Equal("Total items: 500", () => Browser.Exists(By.Id("vht-total-items")).Text); + Browser.True(() => Browser.Exists(By.Id("vht-row-0")).Displayed); + + // Scroll to initial offset and wait deterministically for re-render + js.ExecuteScript("window.scrollTo(0, 1000)"); + Browser.True(() => Browser.FindElements(By.Id("vht-row-10")).Count > 0); + + var result = ExecuteViewportScrollJumpDetectionScript("variable-height-table"); + + var maxJump = Convert.ToInt64(result["maxJump"], CultureInfo.InvariantCulture); + var totalDelta = Convert.ToInt64(result["totalDelta"], CultureInfo.InvariantCulture); + var expectedDelta = Convert.ToInt64(result["expected"], CultureInfo.InvariantCulture); + + // Without the fix: individual jumps of 3000-8000px, total drift 30,000+. + // With the fix: ~300px per step, total ~4500px. + Assert.True(maxJump < 1500, + $"Table scroll should not produce wild jumps. " + + $"Max single jump was {maxJump}px (expected ~300px each). " + + $"Total: {result["startY"]}->{result["endY"]} = {totalDelta}px (expected ~{expectedDelta}px). " + + $"Jumps: [{result["jumps"]}]. " + + $"Large jumps indicate CSS scroll anchoring is miscalculating on elements."); + } + private static (string index, double relTop, long scrollTop) GetItemPositionInContainer( IJavaScriptExecutor js, IWebElement container, string itemSelector, string dataIndex = null) { @@ -1978,4 +2011,77 @@ private Dictionary ExecuteScrollStabilityScript(string container return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); } + + /// + /// Performs incremental viewport-level scrolls (window.scrollBy) on a table element, + /// using MutationObserver + requestAnimationFrame to deterministically wait for each + /// render cycle to settle. Measures per-step scroll jumps to detect CSS Scroll Anchoring + /// miscalculations on <tr> elements. + /// Returns startY, endY, totalDelta, expected, maxJump, and comma-separated jumps. + /// + private Dictionary ExecuteViewportScrollJumpDetectionScript( + string tableId, int scrollCount = 15, int scrollDelta = 300) + { + var script = $@" + var done = arguments[0]; + (async () => {{ + const tbody = document.querySelector('#{tableId} > tbody'); + + // Event-driven wait: watches for DOM mutations from C# re-renders, + // then waits 3 animation frames with no new mutations (layout settled). + // Falls back after 30 frames if no mutations occur. + const waitForRenderSettle = () => new Promise(resolve => {{ + let mutationSeen = false; + let framesSinceLastMutation = 0; + let totalFrames = 0; + + const mo = new MutationObserver(() => {{ + mutationSeen = true; + framesSinceLastMutation = 0; + }}); + mo.observe(tbody, {{ childList: true, subtree: true }}); + + const checkSettle = () => {{ + totalFrames++; + framesSinceLastMutation++; + if ((mutationSeen && framesSinceLastMutation >= 3) || totalFrames >= 30) {{ + mo.disconnect(); + resolve(); + }} else {{ + requestAnimationFrame(checkSettle); + }} + }}; + requestAnimationFrame(checkSettle); + }}); + + const scrollCount = {scrollCount}; + const scrollDelta = {scrollDelta}; + const startY = Math.round(window.scrollY); + let maxJump = 0; + let prevY = startY; + const jumps = []; + + for (let i = 0; i < scrollCount; i++) {{ + window.scrollBy(0, scrollDelta); + await waitForRenderSettle(); + + const currentY = Math.round(window.scrollY); + const jump = currentY - prevY; + jumps.push(jump); + if (Math.abs(jump) > Math.abs(maxJump)) maxJump = jump; + prevY = currentY; + }} + + done({{ + startY: startY, + endY: Math.round(window.scrollY), + totalDelta: Math.round(window.scrollY) - startY, + expected: scrollCount * scrollDelta, + maxJump: maxJump, + jumps: jumps.join(',') + }}); + }})();"; + + return (Dictionary)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(script); + } } From 31dd0af9ce014ca554b875c010ad1310b04da24c Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 26 Mar 2026 16:09:46 +0100 Subject: [PATCH 07/16] Remove redundant comments. --- src/Components/Web.JS/src/Virtualize.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index c34f009576d2..bb6051503154 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -80,11 +80,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectionObserver.observe(spacerAfter); const anchoredItems: Map = new Map(); - - // Track whether the current render was triggered by scrolling (IntersectionObserver - // → C# recalculates → render). During scroll-triggered renders, observing table items - // with ResizeObserver causes layout interference that creates scroll drift. We only - // observe items during data-triggered renders (dynamic content changes). let scrollTriggeredRender = false; function getObservedHeight(entry: ResizeObserverEntry): number { @@ -163,12 +158,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); - // Item observation for manual scroll compensation: - // - When using native anchoring: browser handles above-viewport resizes - // automatically. Observing items here would cause double compensation. - // - When using manual compensation (tables + unsupported browsers): observe items - // so the ResizeObserver can compensate scrollTop. But only during data-triggered - // renders — observing during scroll-triggered renders causes layout interference. + // - Native anchoring: browser handles above-viewport resizes automatically. + // - Manual compensation: observe items on data-triggered renders to compensate. if (useNativeAnchoring || scrollTriggeredRender) { scrollTriggeredRender = false; return; From 40d5c635a3676e13b0b19d557f7290673d310491 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 26 Mar 2026 16:49:13 +0100 Subject: [PATCH 08/16] Prepended items over viewport do not move it if we operate in-memory. --- .../Web/src/Virtualization/Virtualize.cs | 30 ++++- .../test/E2ETest/Tests/VirtualizationTest.cs | 110 ++++++++++++++++++ .../VirtualizationDynamicContent.razor | 32 ++++- 3 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/Components/Web/src/Virtualization/Virtualize.cs b/src/Components/Web/src/Virtualization/Virtualize.cs index 6622face0396..a90372571ca3 100644 --- a/src/Components/Web/src/Virtualization/Virtualize.cs +++ b/src/Components/Web/src/Virtualization/Virtualize.cs @@ -47,6 +47,9 @@ public sealed class Virtualize : ComponentBase, IVirtualizeJsCallbacks, I private IEnumerable? _loadedItems; + // For in-memory Items where objects have stable identity + private TItem? _previousFirstLoadedItem; + private CancellationTokenSource? _refreshCts; private bool _skipNextDistributionRefresh; @@ -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; diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index e0a5a5bd79d9..ef2b8e4558f8 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -1029,6 +1029,116 @@ public void DynamicContent_ExpandingOffScreenItemDoesNotAffectVisibleItems() $"Visible item should not have moved when off-screen item expanded. Before: {firstVisibleTopBefore}, After: {firstVisibleTopAfter}"); } + [Fact] + public void DynamicContent_PrependItemsWhileScrolledToMiddle_VisibleItemsStayInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll past the overscan window to force true virtualization. + js.ExecuteScript("arguments[0].scrollTop = 5000", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 5000); + + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".item")); + if (items.Count == 0) + { + return false; + } + + var idx = items.First().GetDomAttribute("data-index"); + return idx != null && int.Parse(idx, CultureInfo.InvariantCulture) > 10; + }); + + // Record the first visible item and its Y position. + var containerRect = container.Location; + var containerHeight = container.Size.Height; + var visibleItems = container.FindElements(By.CssSelector(".item")); + string firstVisibleIndex = null; + int firstVisibleTopBefore = 0; + foreach (var item in visibleItems) + { + var relativeY = item.Location.Y - containerRect.Y; + if (relativeY >= 0 && relativeY < containerHeight) + { + firstVisibleIndex = item.GetDomAttribute("data-index"); + firstVisibleTopBefore = item.Location.Y; + break; + } + } + Assert.NotNull(firstVisibleIndex); + + Browser.Exists(By.Id("prepend-items")).Click(); + Browser.Contains("Prepended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // The visible item should stay in place after prepend. + var sameItem = container.FindElement(By.CssSelector($"[data-index='{firstVisibleIndex}']")); + var firstVisibleTopAfter = sameItem.Location.Y; + + Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, + $"Visible item {firstVisibleIndex} should not move when items are prepended above. " + + $"Before Y: {firstVisibleTopBefore}, After Y: {firstVisibleTopAfter}"); + + Browser.True(() => container.FindElements(By.CssSelector(".item")).Count > 0); + + // Verify prepended items are reachable at the top. + js.ExecuteScript("arguments[0].scrollTop = 0", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) == 0); + Browser.True(() => container.FindElements(By.CssSelector("[data-index='-10']")).Count > 0); + var topItems = container.FindElements(By.CssSelector(".item")); + Assert.True(topItems.Count > 0, "Should render items after scrolling back to top."); + } + + [Fact] + public void DynamicContent_AppendItemsWhileScrolledToMiddle_VisibleItemsStayInPlace() + { + Browser.MountTestComponent(); + + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + // Scroll past the overscan window to force true virtualization. + js.ExecuteScript("arguments[0].scrollTop = 5000", container); + Browser.True(() => (long)js.ExecuteScript("return arguments[0].scrollTop", container) >= 5000); + + Browser.True(() => + { + var items = container.FindElements(By.CssSelector(".item")); + if (items.Count == 0) + { + return false; + } + + var idx = items.First().GetDomAttribute("data-index"); + return idx != null && int.Parse(idx, CultureInfo.InvariantCulture) > 10; + }); + var visibleItems = container.FindElements(By.CssSelector(".item")); + var firstVisible = visibleItems.First(); + var firstVisibleIndex = firstVisible.GetDomAttribute("data-index"); + var firstVisibleTopBefore = firstVisible.Location.Y; + var scrollTopBefore = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Browser.Exists(By.Id("append-items")).Click(); + Browser.Contains("Appended 10 items", () => Browser.Exists(By.Id("status")).Text); + + // Appending below the viewport should not affect visible items. + var sameItem = container.FindElement(By.CssSelector($"[data-index='{firstVisibleIndex}']")); + var firstVisibleTopAfter = sameItem.Location.Y; + var scrollTopAfter = (long)js.ExecuteScript("return arguments[0].scrollTop", container); + + Assert.True(Math.Abs(firstVisibleTopAfter - firstVisibleTopBefore) < 5, + $"Visible item {firstVisibleIndex} should not move when items are appended below. " + + $"Before: {firstVisibleTopBefore}, After: {firstVisibleTopAfter}"); + Assert.True(Math.Abs(scrollTopAfter - scrollTopBefore) < 5, + $"scrollTop should not change when appending below viewport. " + + $"Before: {scrollTopBefore}, After: {scrollTopAfter}"); + } + [Fact] public void VariableHeight_ContainerResizeWorks() { diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor index 7188b11abcc9..5230a0f0c8e3 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationDynamicContent.razor @@ -25,6 +25,8 @@ + +

@statusMessage

@@ -35,8 +37,10 @@ protected override void OnInitialized() { - // 75 items with initial height of 50px (enough for true off-screen virtualization) - items = Enumerable.Range(0, 75) + // 500 items with initial height of 50px. + // With OverscanCount=15 (default), the initial render covers ~37 items (~2300px). + // 500 items provides enough content to force true virtualization when scrolling. + items = Enumerable.Range(0, 500) .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) .ToList(); } @@ -81,6 +85,30 @@ } } + private int nextPrependIndex = -1; + private int nextAppendIndex = 500; + + private void PrependItems() + { + var newItems = Enumerable.Range(0, 10) + .Select(i => new DynamicItem { Index = nextPrependIndex - i, Height = 50, IsExpanded = false }) + .ToList(); + items.InsertRange(0, newItems); + nextPrependIndex -= 10; + statusMessage = $"Prepended 10 items (indices {newItems.Last().Index}..{newItems.First().Index})"; + } + + private void AppendItems() + { + var startIndex = nextAppendIndex; + var newItems = Enumerable.Range(startIndex, 10) + .Select(i => new DynamicItem { Index = i, Height = 50, IsExpanded = false }) + .ToList(); + items.AddRange(newItems); + nextAppendIndex += 10; + statusMessage = $"Appended 10 items (indices {startIndex}..{startIndex + 9})"; + } + private class DynamicItem { public int Index { get; set; } From a1c9e4f443640107a1d5a745acd1da76f00de922 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 26 Mar 2026 17:56:05 +0100 Subject: [PATCH 09/16] Convergence should not fight with anchoring. --- src/Components/Web.JS/src/Virtualize.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index bb6051503154..4e59187dbe04 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -81,6 +81,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const anchoredItems: Map = new Map(); let scrollTriggeredRender = false; + let isConverging = false; + let scrollToBottomPending = false; function getObservedHeight(entry: ResizeObserverEntry): number { return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; @@ -149,9 +151,16 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } if (useNativeAnchoring) { - // Re-apply overflow-anchor: none on spacers after C# re-renders. spacerBefore.style.overflowAnchor = 'none'; spacerAfter.style.overflowAnchor = 'none'; + + if (isConverging && !scrollToBottomPending) { + isConverging = false; + } + scrollToBottomPending = false; + + // Prevent browser anchoring fighting with convergence. + scrollElement.style.overflowAnchor = isConverging ? 'none' : ''; } // Ensure spacers are always observed (idempotent). @@ -192,6 +201,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver, refreshObservedElements, scrollElement, + setConverging(converging: boolean, pending: boolean) { + isConverging = converging; + scrollToBottomPending = pending; + }, onDispose: () => { anchoredItems.clear(); resizeObserver.disconnect(); @@ -275,6 +288,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; if (entry) { + entry.setConverging(true, true); entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; } } From b00d7eeee10f88a01ec63ff0ab54feba9bd06ee8 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 26 Mar 2026 18:09:25 +0100 Subject: [PATCH 10/16] Missing change for previous commit: fighting can also happen for manual anchoring. --- src/Components/Web.JS/src/Virtualize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 4e59187dbe04..cbd3e8787d5a 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -134,7 +134,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - if (scrollDelta !== 0 && scrollElement.scrollTop > 0) { + if (scrollDelta !== 0 && scrollElement.scrollTop > 0 && !isConverging) { scrollElement.scrollTop += scrollDelta; } }); From e46c5a379f737acdfa7910fe7f47d5032a19fc7a Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 27 Mar 2026 09:40:26 +0100 Subject: [PATCH 11/16] More diagnostics to investigate CI failures that cannot be reproduced locally. --- src/Components/Web.JS/src/Virtualize.ts | 8 ++++++++ src/Components/test/E2ETest/Tests/VirtualizationTest.cs | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index cbd3e8787d5a..f983e79be15a 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -70,6 +70,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // Manual compensation path for tables and browsers without native anchoring. scrollElement.style.overflowAnchor = 'none'; } + console.log(`[Virtualize:init] useNativeAnchoring=${useNativeAnchoring}, isTable=${isTable}, supportsAnchor=${supportsAnchor}`); const intersectionObserver = new IntersectionObserver(intersectionCallback, { root: scrollContainer, @@ -135,6 +136,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } if (scrollDelta !== 0 && scrollElement.scrollTop > 0 && !isConverging) { + console.log(`[Virtualize:ResizeObs] scrollDelta=${scrollDelta}, scrollTop=${scrollElement.scrollTop}, isConverging=${isConverging}`); scrollElement.scrollTop += scrollDelta; } }); @@ -161,6 +163,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // Prevent browser anchoring fighting with convergence. scrollElement.style.overflowAnchor = isConverging ? 'none' : ''; + console.log(`[Virtualize:refresh] isConverging=${isConverging}, scrollTop=${scrollElement.scrollTop}, scrollHeight=${scrollElement.scrollHeight}, clientHeight=${scrollElement.clientHeight}, spacerAfter.h=${spacerAfter.offsetHeight}, spacerBefore.h=${spacerBefore.offsetHeight}, anchor=${scrollElement.style.overflowAnchor}`); } // Ensure spacers are always observed (idempotent). @@ -263,12 +266,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (entry.target === spacerBefore) { const spacerSize = (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor; + console.log(`[Virtualize:IO] spacerBefore visible, spacerSize=${spacerSize.toFixed(1)}, scrollTop=${scrollElement.scrollTop}`); dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize); } else if (entry.target === spacerAfter && spacerAfter.offsetHeight > 0) { // When we first start up, both the "before" and "after" spacers will be visible, but it's only relevant to raise a // single event to load the initial data. To avoid raising two events, skip the one for the "after" spacer if we know // it's meaningless to talk about any overlap into it. const spacerSize = (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor; + console.log(`[Virtualize:IO] spacerAfter visible, spacerSize=${spacerSize.toFixed(1)}, offsetHeight=${spacerAfter.offsetHeight}, scrollTop=${scrollElement.scrollTop}`); dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', spacerSize, spacerSeparation, containerSize); } }); @@ -288,8 +293,11 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; if (entry) { + const el = entry.scrollElement; + console.log(`[Virtualize:scrollToBottom] before: scrollTop=${el.scrollTop}, scrollHeight=${el.scrollHeight}`); entry.setConverging(true, true); entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; + console.log(`[Virtualize:scrollToBottom] after: scrollTop=${el.scrollTop}`); } } diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index ef2b8e4558f8..41c2dad63270 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -777,14 +777,18 @@ private void JumpToEndWithStabilization( var metrics = (IReadOnlyDictionary)js.ExecuteScript( "var c = arguments[0]; var spacers = c.querySelectorAll('[aria-hidden]');" + "return { spacerAfterHeight: spacers.length >= 2 ? spacers[spacers.length - 1].offsetHeight : 999," + + " spacerBeforeHeight: spacers.length >= 1 ? spacers[0].offsetHeight : -1," + + " overflowAnchor: getComputedStyle(c).overflowAnchor || 'N/A'," + " scrollTop: c.scrollTop, scrollHeight: c.scrollHeight, clientHeight: c.clientHeight };", container); var spacerAfterHeight = Convert.ToDouble(metrics["spacerAfterHeight"], CultureInfo.InvariantCulture); + var spacerBeforeHeight = Convert.ToDouble(metrics["spacerBeforeHeight"], CultureInfo.InvariantCulture); + var overflowAnchor = metrics["overflowAnchor"]?.ToString() ?? "N/A"; var scrollTop = Convert.ToDouble(metrics["scrollTop"], CultureInfo.InvariantCulture); var scrollHeight = Convert.ToDouble(metrics["scrollHeight"], CultureInfo.InvariantCulture); var clientHeight = Convert.ToDouble(metrics["clientHeight"], CultureInfo.InvariantCulture); var remaining = scrollHeight - scrollTop - clientHeight; - endDiagnostics.AppendLine(CultureInfo.InvariantCulture, $" poll #{endPollCount}: spacerAfter={spacerAfterHeight:F1}, scrollTop={scrollTop:F1}, scrollHeight={scrollHeight:F1}, clientHeight={clientHeight:F1}, remaining={remaining:F1}"); + endDiagnostics.AppendLine(CultureInfo.InvariantCulture, $" poll #{endPollCount}: spacerAfter={spacerAfterHeight:F1}, spacerBefore={spacerBeforeHeight:F1}, scrollTop={scrollTop:F1}, scrollHeight={scrollHeight:F1}, clientHeight={clientHeight:F1}, remaining={remaining:F1}, overflowAnchor={overflowAnchor}"); return spacerAfterHeight < 1; }); From 52d666705cb5d82f24cd63761bf12d05ce4a06a5 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 27 Mar 2026 11:20:33 +0100 Subject: [PATCH 12/16] Stateful approach + diagnostics. --- src/Components/Web.JS/src/Virtualize.ts | 46 +++++++++++++++---------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index f983e79be15a..79115ebd361a 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -82,8 +82,21 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const anchoredItems: Map = new Map(); let scrollTriggeredRender = false; - let isConverging = false; - let scrollToBottomPending = false; + + let convergingToEnd = false; + let convergingToStart = false; + + scrollElement.addEventListener('scroll', () => { + const remaining = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; + if (remaining < 1 && spacerAfter.offsetHeight > 0) { + console.log(`[Virtualize:scroll] At bottom, spacerAfter=${spacerAfter.offsetHeight} → convergingToEnd=true`); + convergingToEnd = true; + } + if (scrollElement.scrollTop < 1 && spacerBefore.offsetHeight > 0) { + console.log(`[Virtualize:scroll] At top, spacerBefore=${spacerBefore.offsetHeight} → convergingToStart=true`); + convergingToStart = true; + } + }, { passive: true }); function getObservedHeight(entry: ResizeObserverEntry): number { return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; @@ -135,8 +148,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - if (scrollDelta !== 0 && scrollElement.scrollTop > 0 && !isConverging) { - console.log(`[Virtualize:ResizeObs] scrollDelta=${scrollDelta}, scrollTop=${scrollElement.scrollTop}, isConverging=${isConverging}`); + if (scrollDelta !== 0 && scrollElement.scrollTop > 0 && !convergingToEnd && !convergingToStart) { + console.log(`[Virtualize:ResizeObs] scrollDelta=${scrollDelta}, scrollTop=${scrollElement.scrollTop}`); scrollElement.scrollTop += scrollDelta; } }); @@ -156,14 +169,19 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac spacerBefore.style.overflowAnchor = 'none'; spacerAfter.style.overflowAnchor = 'none'; - if (isConverging && !scrollToBottomPending) { - isConverging = false; + // Clear convergence flags once the target spacer reaches zero height. + if (convergingToEnd && spacerAfter.offsetHeight < 1) { + console.log('[Virtualize:refresh] convergingToEnd complete — spacerAfter=0'); + convergingToEnd = false; + } + if (convergingToStart && spacerBefore.offsetHeight < 1) { + console.log('[Virtualize:refresh] convergingToStart complete — spacerBefore=0'); + convergingToStart = false; } - scrollToBottomPending = false; - // Prevent browser anchoring fighting with convergence. + const isConverging = convergingToEnd || convergingToStart; scrollElement.style.overflowAnchor = isConverging ? 'none' : ''; - console.log(`[Virtualize:refresh] isConverging=${isConverging}, scrollTop=${scrollElement.scrollTop}, scrollHeight=${scrollElement.scrollHeight}, clientHeight=${scrollElement.clientHeight}, spacerAfter.h=${spacerAfter.offsetHeight}, spacerBefore.h=${spacerBefore.offsetHeight}, anchor=${scrollElement.style.overflowAnchor}`); + console.log(`[Virtualize:refresh] convergingToEnd=${convergingToEnd}, convergingToStart=${convergingToStart}, overflowAnchor=${scrollElement.style.overflowAnchor}, scrollTop=${scrollElement.scrollTop}, scrollHeight=${scrollElement.scrollHeight}, clientHeight=${scrollElement.clientHeight}, spacerAfter.h=${spacerAfter.offsetHeight}, spacerBefore.h=${spacerBefore.offsetHeight}`); } // Ensure spacers are always observed (idempotent). @@ -204,10 +222,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver, refreshObservedElements, scrollElement, - setConverging(converging: boolean, pending: boolean) { - isConverging = converging; - scrollToBottomPending = pending; - }, onDispose: () => { anchoredItems.clear(); resizeObserver.disconnect(); @@ -266,14 +280,12 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (entry.target === spacerBefore) { const spacerSize = (entry.intersectionRect.top - entry.boundingClientRect.top) / scaleFactor; - console.log(`[Virtualize:IO] spacerBefore visible, spacerSize=${spacerSize.toFixed(1)}, scrollTop=${scrollElement.scrollTop}`); dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', spacerSize, spacerSeparation, containerSize); } else if (entry.target === spacerAfter && spacerAfter.offsetHeight > 0) { // When we first start up, both the "before" and "after" spacers will be visible, but it's only relevant to raise a // single event to load the initial data. To avoid raising two events, skip the one for the "after" spacer if we know // it's meaningless to talk about any overlap into it. const spacerSize = (entry.boundingClientRect.bottom - entry.intersectionRect.bottom) / scaleFactor; - console.log(`[Virtualize:IO] spacerAfter visible, spacerSize=${spacerSize.toFixed(1)}, offsetHeight=${spacerAfter.offsetHeight}, scrollTop=${scrollElement.scrollTop}`); dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', spacerSize, spacerSeparation, containerSize); } }); @@ -293,11 +305,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; if (entry) { - const el = entry.scrollElement; - console.log(`[Virtualize:scrollToBottom] before: scrollTop=${el.scrollTop}, scrollHeight=${el.scrollHeight}`); - entry.setConverging(true, true); entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; - console.log(`[Virtualize:scrollToBottom] after: scrollTop=${el.scrollTop}`); } } From fd6b423c39a092911e1eed7c2d2377eb70c6e0ca Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 27 Mar 2026 12:44:39 +0100 Subject: [PATCH 13/16] Fix + diagnostics. --- src/Components/Web.JS/src/Virtualize.ts | 36 ++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 79115ebd361a..190394d7c904 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -120,6 +120,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } + // During convergence, any item resize should re-scroll to the target + // extremity. This mirrors what the old code did — the ResizeObserver drives + // convergence by keeping scrollTop pinned while items load and resize. + if (convergingToEnd || convergingToStart) { + if (convergingToEnd) { + scrollElement.scrollTop = scrollElement.scrollHeight; + } else { + scrollElement.scrollTop = 0; + } + const spacer = convergingToEnd ? spacerAfter : spacerBefore; + if (spacer.offsetHeight < 1) { + console.log(`[Virtualize:ResizeObs] convergence complete — spacer height=0`); + convergingToEnd = convergingToStart = false; + } + return; + } + // 2. Viewport anchoring: compensate scroll for above-viewport item resizes. let scrollDelta = 0; const containerTop = scrollContainer @@ -181,6 +198,16 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const isConverging = convergingToEnd || convergingToStart; scrollElement.style.overflowAnchor = isConverging ? 'none' : ''; + + if (convergingToEnd) { + console.log(`[Virtualize:refresh] convergingToEnd: scrolling to bottom (scrollTop ${scrollElement.scrollTop} → ${scrollElement.scrollHeight})`); + scrollElement.scrollTop = scrollElement.scrollHeight; + } + if (convergingToStart) { + console.log(`[Virtualize:refresh] convergingToStart: scrolling to top (scrollTop ${scrollElement.scrollTop} → 0)`); + scrollElement.scrollTop = 0; + } + console.log(`[Virtualize:refresh] convergingToEnd=${convergingToEnd}, convergingToStart=${convergingToStart}, overflowAnchor=${scrollElement.style.overflowAnchor}, scrollTop=${scrollElement.scrollTop}, scrollHeight=${scrollElement.scrollHeight}, clientHeight=${scrollElement.clientHeight}, spacerAfter.h=${spacerAfter.offsetHeight}, spacerBefore.h=${spacerBefore.offsetHeight}`); } @@ -190,14 +217,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // - Native anchoring: browser handles above-viewport resizes automatically. // - Manual compensation: observe items on data-triggered renders to compensate. - if (useNativeAnchoring || scrollTriggeredRender) { + // - During convergence: observe items so ResizeObserver can drive re-scrolling. + const isConvergingNow = convergingToEnd || convergingToStart; + if ((useNativeAnchoring && !isConvergingNow) || scrollTriggeredRender) { scrollTriggeredRender = false; return; } scrollTriggeredRender = false; - // Observe all rendered items for viewport anchoring. When an item - // resizes above the viewport, the ResizeObserver callback compensates scrollTop. + // Observe all rendered items. During normal manual-compensation mode, the + // ResizeObserver callback compensates scrollTop. During convergence, it + // re-scrolls to the target extremity. const currentItems = new Set(); for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { resizeObserver.observe(el); From d17061d3d8e672c4eef2125b2e1ff329fc2742d0 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 27 Mar 2026 15:08:56 +0100 Subject: [PATCH 14/16] Tmp fix. --- src/Components/Web.JS/src/Virtualize.ts | 36 +++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 190394d7c904..9f7449db17c8 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -86,15 +86,39 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac let convergingToEnd = false; let convergingToStart = false; + // Detect End/Home key presses to begin convergence immediately, before the + // browser's scroll position even updates. This mirrors the old code's keydown + // listener. Without this, CSS scroll anchoring can prevent scrollTop from + // reaching the absolute bottom, so the scroll-event-based detection below + // would never fire. + const keyTarget = scrollContainer || document; + keyTarget.addEventListener('keydown', (e: Event) => { + const ke = e as KeyboardEvent; + if (ke.key === 'End' && !convergingToEnd && spacerAfter.offsetHeight > 0) { + console.log(`[Virtualize:keydown] End key → convergingToEnd=true, spacerAfter=${spacerAfter.offsetHeight}`); + convergingToEnd = true; + scrollElement.style.overflowAnchor = 'none'; + } + if (ke.key === 'Home' && !convergingToStart && spacerBefore.offsetHeight > 0) { + console.log(`[Virtualize:keydown] Home key → convergingToStart=true, spacerBefore=${spacerBefore.offsetHeight}`); + convergingToStart = true; + scrollElement.style.overflowAnchor = 'none'; + } + }); + + // Also detect via scroll events — catches programmatic scrollToBottom + // and any other path that reaches the extremity. scrollElement.addEventListener('scroll', () => { const remaining = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; - if (remaining < 1 && spacerAfter.offsetHeight > 0) { + if (remaining < 1 && spacerAfter.offsetHeight > 0 && !convergingToEnd) { console.log(`[Virtualize:scroll] At bottom, spacerAfter=${spacerAfter.offsetHeight} → convergingToEnd=true`); convergingToEnd = true; + scrollElement.style.overflowAnchor = 'none'; } - if (scrollElement.scrollTop < 1 && spacerBefore.offsetHeight > 0) { + if (scrollElement.scrollTop < 1 && spacerBefore.offsetHeight > 0 && !convergingToStart) { console.log(`[Virtualize:scroll] At top, spacerBefore=${spacerBefore.offsetHeight} → convergingToStart=true`); convergingToStart = true; + scrollElement.style.overflowAnchor = 'none'; } }, { passive: true }); @@ -252,6 +276,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver, refreshObservedElements, scrollElement, + startConvergingToEnd: () => { + if (!convergingToEnd && spacerAfter.offsetHeight > 0) { + console.log(`[Virtualize:scrollToBottom] → convergingToEnd=true, spacerAfter=${spacerAfter.offsetHeight}`); + convergingToEnd = true; + scrollElement.style.overflowAnchor = 'none'; + } + }, onDispose: () => { anchoredItems.clear(); resizeObserver.disconnect(); @@ -335,6 +366,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; if (entry) { + entry.startConvergingToEnd?.(); entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; } } From 5e41e688b0b51a2c4d457fd2b578b159307c2488 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 27 Mar 2026 16:17:41 +0100 Subject: [PATCH 15/16] Initial cleanup. --- src/Components/Web.JS/src/Virtualize.ts | 326 ++++++++++++++---------- 1 file changed, 192 insertions(+), 134 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 9f7449db17c8..5f1e7cc0e6bc 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -63,14 +63,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } if (useNativeAnchoring) { - // Applied to rendered items - keeps viewport stable when spacer heights change. + // 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'; } - console.log(`[Virtualize:init] useNativeAnchoring=${useNativeAnchoring}, isTable=${isTable}, supportsAnchor=${supportsAnchor}`); const intersectionObserver = new IntersectionObserver(intersectionCallback, { root: scrollContainer, @@ -80,48 +79,14 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectionObserver.observe(spacerBefore); intersectionObserver.observe(spacerAfter); + let convergingElements = false; + let convergenceItems: Set = new Set(); + + // Manual scroll compensation state — tracks item heights so we can adjust scrollTop + // when above-viewport items resize. Only used when native anchoring is unavailable. const anchoredItems: Map = new Map(); let scrollTriggeredRender = false; - let convergingToEnd = false; - let convergingToStart = false; - - // Detect End/Home key presses to begin convergence immediately, before the - // browser's scroll position even updates. This mirrors the old code's keydown - // listener. Without this, CSS scroll anchoring can prevent scrollTop from - // reaching the absolute bottom, so the scroll-event-based detection below - // would never fire. - const keyTarget = scrollContainer || document; - keyTarget.addEventListener('keydown', (e: Event) => { - const ke = e as KeyboardEvent; - if (ke.key === 'End' && !convergingToEnd && spacerAfter.offsetHeight > 0) { - console.log(`[Virtualize:keydown] End key → convergingToEnd=true, spacerAfter=${spacerAfter.offsetHeight}`); - convergingToEnd = true; - scrollElement.style.overflowAnchor = 'none'; - } - if (ke.key === 'Home' && !convergingToStart && spacerBefore.offsetHeight > 0) { - console.log(`[Virtualize:keydown] Home key → convergingToStart=true, spacerBefore=${spacerBefore.offsetHeight}`); - convergingToStart = true; - scrollElement.style.overflowAnchor = 'none'; - } - }); - - // Also detect via scroll events — catches programmatic scrollToBottom - // and any other path that reaches the extremity. - scrollElement.addEventListener('scroll', () => { - const remaining = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; - if (remaining < 1 && spacerAfter.offsetHeight > 0 && !convergingToEnd) { - console.log(`[Virtualize:scroll] At bottom, spacerAfter=${spacerAfter.offsetHeight} → convergingToEnd=true`); - convergingToEnd = true; - scrollElement.style.overflowAnchor = 'none'; - } - if (scrollElement.scrollTop < 1 && spacerBefore.offsetHeight > 0 && !convergingToStart) { - console.log(`[Virtualize:scroll] At top, spacerBefore=${spacerBefore.offsetHeight} → convergingToStart=true`); - convergingToStart = true; - scrollElement.style.overflowAnchor = 'none'; - } - }, { passive: true }); - function getObservedHeight(entry: ResizeObserverEntry): number { return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; } @@ -129,11 +94,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // 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. Viewport anchoring — compensates scroll position when content above the viewport resizes. - // When native anchoring is available (non-table + supported browser), the browser handles this. - // Otherwise (tables, Safari), we use manual compensation via item height tracking. + // 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 => { - // 1. Re-trigger IntersectionObserver for spacer resizes. for (const entry of entries) { if (entry.target === spacerBefore || entry.target === spacerAfter) { const spacer = entry.target as HTMLElement; @@ -144,54 +107,49 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } - // During convergence, any item resize should re-scroll to the target - // extremity. This mirrors what the old code did — the ResizeObserver drives - // convergence by keeping scrollTop pinned while items load and resize. - if (convergingToEnd || convergingToStart) { - if (convergingToEnd) { - scrollElement.scrollTop = scrollElement.scrollHeight; - } else { - scrollElement.scrollTop = 0; - } - const spacer = convergingToEnd ? spacerAfter : spacerBefore; - if (spacer.offsetHeight < 1) { - console.log(`[Virtualize:ResizeObs] convergence complete — spacer height=0`); - convergingToEnd = convergingToStart = false; + // Convergence logic: keep scroll pinned to top/bottom while items load. + if (convergingToBottom || convergingToTop) { + scrollElement.scrollTop = convergingToBottom ? scrollElement.scrollHeight : 0; + const spacer = convergingToBottom ? spacerAfter : spacerBefore; + if (spacer.offsetHeight === 0) { + convergingToBottom = convergingToTop = false; + stopConvergenceObserving(); } return; + } else if (convergingElements) { + stopConvergenceObserving(); } - // 2. Viewport anchoring: compensate scroll for above-viewport item resizes. - let scrollDelta = 0; - const containerTop = scrollContainer - ? scrollContainer.getBoundingClientRect().top - : 0; - - for (const entry of entries) { - if (entry.target === spacerBefore || entry.target === spacerAfter) { - // Skip spacer entries — spacers resize during normal scroll-driven - // rendering. Compensating here would undo normal scrolling. - continue; - } + // Manual scroll compensation: adjust scrollTop for above-viewport item resizes. + // When native anchoring is available, the browser handles this automatically. + if (!useNativeAnchoring) { + 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 (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) { - // Compensate if the element is above the viewport (fully or partially). - if (el.getBoundingClientRect().top < containerTop) { - scrollDelta += (newHeight - oldHeight); + if (oldHeight !== undefined && oldHeight !== newHeight) { + if (el.getBoundingClientRect().top < containerTop) { + scrollDelta += (newHeight - oldHeight); + } } } } - } - if (scrollDelta !== 0 && scrollElement.scrollTop > 0 && !convergingToEnd && !convergingToStart) { - console.log(`[Virtualize:ResizeObs] scrollDelta=${scrollDelta}, scrollTop=${scrollElement.scrollTop}`); - scrollElement.scrollTop += scrollDelta; + if (scrollDelta !== 0 && scrollElement.scrollTop > 0) { + scrollElement.scrollTop += scrollDelta; + } } }); @@ -209,64 +167,113 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac if (useNativeAnchoring) { spacerBefore.style.overflowAnchor = 'none'; spacerAfter.style.overflowAnchor = 'none'; - - // Clear convergence flags once the target spacer reaches zero height. - if (convergingToEnd && spacerAfter.offsetHeight < 1) { - console.log('[Virtualize:refresh] convergingToEnd complete — spacerAfter=0'); - convergingToEnd = false; - } - if (convergingToStart && spacerBefore.offsetHeight < 1) { - console.log('[Virtualize:refresh] convergingToStart complete — spacerBefore=0'); - convergingToStart = false; - } - - const isConverging = convergingToEnd || convergingToStart; - scrollElement.style.overflowAnchor = isConverging ? 'none' : ''; - - if (convergingToEnd) { - console.log(`[Virtualize:refresh] convergingToEnd: scrolling to bottom (scrollTop ${scrollElement.scrollTop} → ${scrollElement.scrollHeight})`); - scrollElement.scrollTop = scrollElement.scrollHeight; - } - if (convergingToStart) { - console.log(`[Virtualize:refresh] convergingToStart: scrolling to top (scrollTop ${scrollElement.scrollTop} → 0)`); - scrollElement.scrollTop = 0; - } - - console.log(`[Virtualize:refresh] convergingToEnd=${convergingToEnd}, convergingToStart=${convergingToStart}, overflowAnchor=${scrollElement.style.overflowAnchor}, scrollTop=${scrollElement.scrollTop}, scrollHeight=${scrollElement.scrollHeight}, clientHeight=${scrollElement.clientHeight}, spacerAfter.h=${spacerAfter.offsetHeight}, spacerBefore.h=${spacerBefore.offsetHeight}`); } // Ensure spacers are always observed (idempotent). resizeObserver.observe(spacerBefore); resizeObserver.observe(spacerAfter); - // - Native anchoring: browser handles above-viewport resizes automatically. - // - Manual compensation: observe items on data-triggered renders to compensate. - // - During convergence: observe items so ResizeObserver can drive re-scrolling. - const isConvergingNow = convergingToEnd || convergingToStart; - if ((useNativeAnchoring && !isConvergingNow) || scrollTriggeredRender) { - scrollTriggeredRender = false; + // During convergence, keep the observed element set in sync with the DOM. + if (convergingElements) { + const currentItems: Set = new Set(); + for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { + resizeObserver.observe(el); + currentItems.add(el); + } + // Unobserve items removed during re-render. + for (const el of convergenceItems) { + if (!currentItems.has(el)) { + resizeObserver.unobserve(el); + } + } + 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(); + 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; - // Observe all rendered items. During normal manual-compensation mode, the - // ResizeObserver callback compensates scrollTop. During convergence, it - // re-scrolls to the target extremity. - const currentItems = new Set(); + // 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; for (let el = spacerBefore.nextElementSibling; el && el !== spacerAfter; el = el.nextElementSibling) { resizeObserver.observe(el); - currentItems.add(el); + convergenceItems.add(el); } + } - // Unobserve items removed during re-render and clean up height tracking. - for (const [el] of anchoredItems) { - if (!currentItems.has(el)) { - resizeObserver.unobserve(el); - anchoredItems.delete(el); - } + function stopConvergenceObserving(): void { + if (!convergingElements) return; + convergingElements = false; + for (const el of convergenceItems) { + resizeObserver.unobserve(el); + } + convergenceItems.clear(); + if (useNativeAnchoring) { + scrollElement.style.overflowAnchor = ''; + } + anchoredItems.clear(); + } + + let convergingToBottom = false; + let convergingToTop = false; + + let pendingJumpToEnd = false; + let pendingJumpToStart = false; + + function startConvergence(toBottom: boolean): void { + if (toBottom) { + if (convergingToBottom || spacerAfter.offsetHeight === 0) return; + convergingToBottom = true; + } else { + if (convergingToTop || spacerBefore.offsetHeight === 0) return; + convergingToTop = true; + } + startConvergenceObserving(); + if (useNativeAnchoring) { + scrollElement.style.overflowAnchor = 'none'; } } + // Detect End/Home key presses to begin convergence immediately, before the + // browser's scroll position updates. With native CSS scroll anchoring enabled, + // the browser may prevent scrollTop from reaching the absolute extremity, so + // the IO-based detection in onSpacerAfterVisible alone is not sufficient. + const keydownTarget: EventTarget = scrollContainer || document; + function handleJumpKeys(e: Event): void { + const ke = e as KeyboardEvent; + if (ke.key === 'End') { + pendingJumpToEnd = true; + pendingJumpToStart = false; + startConvergence(true); + } else if (ke.key === 'Home') { + pendingJumpToStart = true; + pendingJumpToEnd = false; + startConvergence(false); + } + } + keydownTarget.addEventListener('keydown', handleJumpKeys); + const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); let pendingCallbacks: Map = new Map(); let callbackTimeout: ReturnType | null = null; @@ -276,16 +283,12 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver, refreshObservedElements, scrollElement, - startConvergingToEnd: () => { - if (!convergingToEnd && spacerAfter.offsetHeight > 0) { - console.log(`[Virtualize:scrollToBottom] → convergingToEnd=true, spacerAfter=${spacerAfter.offsetHeight}`); - convergingToEnd = true; - scrollElement.style.overflowAnchor = 'none'; - } - }, + startConvergence, onDispose: () => { + stopConvergenceObserving(); anchoredItems.clear(); resizeObserver.disconnect(); + keydownTarget.removeEventListener('keydown', handleJumpKeys); if (callbackTimeout) { clearTimeout(callbackTimeout); callbackTimeout = null; @@ -314,13 +317,68 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } } + function onSpacerAfterVisible(): void { + if (spacerAfter.offsetHeight === 0) { + if (convergingToBottom) { + convergingToBottom = false; + stopConvergenceObserving(); + } + return; + } + if (convergingToBottom) return; + + const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; + if (!atBottom && !pendingJumpToEnd) return; + + startConvergence(true); + if (pendingJumpToEnd) { + scrollElement.scrollTop = scrollElement.scrollHeight; + pendingJumpToEnd = false; + } + } + + function onSpacerBeforeVisible(): void { + if (spacerBefore.offsetHeight === 0) { + if (convergingToTop) { + convergingToTop = false; + stopConvergenceObserving(); + } + return; + } + if (convergingToTop) return; + + const atTop = scrollElement.scrollTop < 1; + if (!atTop && !pendingJumpToStart) return; + + startConvergence(false); + if (pendingJumpToStart) { + scrollElement.scrollTop = 0; + pendingJumpToStart = false; + } + } + function processIntersectionEntries(entries: IntersectionObserverEntry[]): void { // Check if the spacers are still in the DOM. They may have been removed if the component was disposed. if (!spacerBefore.isConnected || !spacerAfter.isConnected) { return; } - const intersectingEntries = entries.filter(entry => entry.isIntersecting); + const intersectingEntries = entries.filter(entry => { + if (entry.isIntersecting) { + if (entry.target === spacerAfter) { + onSpacerAfterVisible(); + } else if (entry.target === spacerBefore) { + onSpacerBeforeVisible(); + } + return true; + } + if (entry.target === spacerAfter && convergingToBottom && spacerAfter.offsetHeight > 0) { + scrollElement.scrollTop = scrollElement.scrollHeight; + } else if (entry.target === spacerBefore && convergingToTop && spacerBefore.offsetHeight > 0) { + scrollElement.scrollTop = 0; + } + return false; + }); if (intersectingEntries.length === 0) { return; @@ -336,7 +394,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor; // Mark the upcoming render as scroll-triggered so refreshObservedElements - // skips item observation for tables (avoids layout interference drift). + // skips item observation (avoids layout interference drift). scrollTriggeredRender = true; if (entry.target === spacerBefore) { @@ -366,7 +424,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; if (entry) { - entry.startConvergingToEnd?.(); + entry.startConvergence?.(true); entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; } } From dd6e3cd4712a7d148b72f8a757095a2c80f38e38 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 27 Mar 2026 17:45:42 +0100 Subject: [PATCH 16/16] Deeper cleanup. --- src/Components/Web.JS/src/Virtualize.ts | 110 +++++++++++------------- 1 file changed, 52 insertions(+), 58 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 5f1e7cc0e6bc..cc6ed8873a41 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -82,8 +82,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac let convergingElements = false; let convergenceItems: Set = new Set(); - // Manual scroll compensation state — tracks item heights so we can adjust scrollTop - // when above-viewport items resize. Only used when native anchoring is unavailable. const anchoredItems: Map = new Map(); let scrollTriggeredRender = false; @@ -91,6 +89,36 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; } + 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. @@ -115,41 +143,13 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac convergingToBottom = convergingToTop = false; stopConvergenceObserving(); } - return; } else if (convergingElements) { stopConvergenceObserving(); } - // Manual scroll compensation: adjust scrollTop for above-viewport item resizes. - // When native anchoring is available, the browser handles this automatically. + // Manual scroll compensation: adjust scrollTop for above-viewport resizes. if (!useNativeAnchoring) { - 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; - } + compensateScrollForItemResizes(entries); } }); @@ -216,6 +216,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac 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); @@ -241,35 +244,23 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac let pendingJumpToEnd = false; let pendingJumpToStart = false; - function startConvergence(toBottom: boolean): void { - if (toBottom) { - if (convergingToBottom || spacerAfter.offsetHeight === 0) return; - convergingToBottom = true; - } else { - if (convergingToTop || spacerBefore.offsetHeight === 0) return; - convergingToTop = true; - } - startConvergenceObserving(); - if (useNativeAnchoring) { - scrollElement.style.overflowAnchor = 'none'; - } - } - - // Detect End/Home key presses to begin convergence immediately, before the - // browser's scroll position updates. With native CSS scroll anchoring enabled, - // the browser may prevent scrollTop from reaching the absolute extremity, so - // the IO-based detection in onSpacerAfterVisible alone is not sufficient. const keydownTarget: EventTarget = scrollContainer || document; function handleJumpKeys(e: Event): void { const ke = e as KeyboardEvent; if (ke.key === 'End') { pendingJumpToEnd = true; pendingJumpToStart = false; - startConvergence(true); + if (!convergingToBottom && spacerAfter.offsetHeight > 0) { + convergingToBottom = true; + startConvergenceObserving(); + } } else if (ke.key === 'Home') { pendingJumpToStart = true; pendingJumpToEnd = false; - startConvergence(false); + if (!convergingToTop && spacerBefore.offsetHeight > 0) { + convergingToTop = true; + startConvergenceObserving(); + } } } keydownTarget.addEventListener('keydown', handleJumpKeys); @@ -283,7 +274,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac resizeObserver, refreshObservedElements, scrollElement, - startConvergence, + startConvergenceObserving, + setConvergingToBottom: () => { convergingToBottom = true; }, onDispose: () => { stopConvergenceObserving(); anchoredItems.clear(); @@ -330,7 +322,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const atBottom = scrollElement.scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 1; if (!atBottom && !pendingJumpToEnd) return; - startConvergence(true); + convergingToBottom = true; + startConvergenceObserving(); if (pendingJumpToEnd) { scrollElement.scrollTop = scrollElement.scrollHeight; pendingJumpToEnd = false; @@ -350,7 +343,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac const atTop = scrollElement.scrollTop < 1; if (!atTop && !pendingJumpToStart) return; - startConvergence(false); + convergingToTop = true; + startConvergenceObserving(); if (pendingJumpToStart) { scrollElement.scrollTop = 0; pendingJumpToStart = false; @@ -393,8 +387,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac intersectingEntries.forEach((entry): void => { const containerSize = (entry.rootBounds?.height ?? 0) / scaleFactor; - // Mark the upcoming render as scroll-triggered so refreshObservedElements - // skips item observation (avoids layout interference drift). + // So that RefreshObservedElements can skip item observation (avoids layout interference drift). scrollTriggeredRender = true; if (entry.target === spacerBefore) { @@ -424,8 +417,9 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void { const { observersByDotNetObjectId, id } = getObserversMapEntry(dotNetHelper); const entry = observersByDotNetObjectId[id]; if (entry) { - entry.startConvergence?.(true); + entry.setConvergingToBottom?.(); entry.scrollElement.scrollTop = entry.scrollElement.scrollHeight; + entry.startConvergenceObserving?.(); } }