From f0733b38e6310b96ab65129829155f5524e0ecc9 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 15 Sep 2026 12:33:35 +0200 Subject: [PATCH 1/7] Add regression test. --- .../test/E2ETest/Tests/VirtualizationTest.cs | 98 +++++++++++++++++++ .../VirtualizationAnchorMode.razor | 13 +++ 2 files changed, 111 insertions(+) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 3ec61edda2ed..5ad1c2bd6eea 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -5458,6 +5458,104 @@ private void MountAnchorModeForScrollToItem(bool useProvider, bool variableHeigh private void SetManualInitialIndex(int index) => SetNumberInputAndWaitForBind("manual-initial-index", index); + [Fact] + public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() + { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + Browser.Exists(By.Id("unload-list")).Click(); + Browser.Exists(By.Id("list-not-loaded")); + Browser.Exists(By.Id("toggle-provider")).Click(); + Browser.Contains("Switched to ItemsProvider", () => Browser.Exists(By.Id("status")).Text); + new SelectElement(Browser.Exists(By.Id("anchor-mode-select"))).SelectByValue("2"); + Browser.Equal("2", () => Browser.Exists(By.Id("current-mode")).Text); + + InstallVirtualizeIntersectionObserverGate(js); + try + { + Browser.Exists(By.Id("reload-with-initial-index")).Click(); + Browser.True(() => Convert.ToBoolean(js.ExecuteScript( + """ + const spacer = document.querySelector( + '#scroll-container [data-blazor-virtualize-reserved-height]'); + return spacer?.style.flexShrink === '0' + && window.__virtualizePendingObserverCallbacks > 0; + """), CultureInfo.InvariantCulture)); + + Browser.Exists(By.Id("refresh-data")).Click(); + Browser.Contains("Refreshed data", () => Browser.Exists(By.Id("status")).Text); + Browser.True(() => GetMaximumScrollTop(js, container) > 0); + + Browser.True( + () => IsScrolledToBottom(js, container), + TimeSpan.FromSeconds(10), + $"Expected the initial provider result to pin to the bottom, but scrollTop was " + + $"{GetScrollTop(js, container)} of {GetMaximumScrollTop(js, container)}."); + + js.ExecuteScript("window.__releaseVirtualizeObserverCallbacks();"); + Browser.True( + () => GetBottomRenderedIndex(js) == 999 && IsScrolledToBottom(js, container), + TimeSpan.FromSeconds(10), + $"Expected item 999 at the pinned tail, but the bottom rendered item was " + + $"{GetBottomRenderedIndex(js)} and scrollTop was {GetScrollTop(js, container)}."); + } + finally + { + js.ExecuteScript("window.__restoreVirtualizeIntersectionObserver?.();"); + } + } + + private static void InstallVirtualizeIntersectionObserverGate(IJavaScriptExecutor js) + { + js.ExecuteScript( + """ + const nativeIntersectionObserver = window.IntersectionObserver; + const pendingCallbacks = []; + window.__virtualizePendingObserverCallbacks = 0; + + window.IntersectionObserver = class extends nativeIntersectionObserver { + constructor(callback, options) { + super((entries, observer) => { + const containsVirtualizeSpacer = entries.some(entry => + entry.target?.hasAttribute?.('data-blazor-virtualize-reserved-height')); + if (containsVirtualizeSpacer) { + pendingCallbacks.push(() => callback(entries, observer)); + window.__virtualizePendingObserverCallbacks = pendingCallbacks.length; + return; + } + + callback(entries, observer); + }, options); + } + }; + + window.__releaseVirtualizeObserverCallbacks = () => { + for (const callback of pendingCallbacks.splice(0)) { + callback(); + } + window.__virtualizePendingObserverCallbacks = 0; + }; + + window.__restoreVirtualizeIntersectionObserver = () => { + window.__releaseVirtualizeObserverCallbacks(); + window.IntersectionObserver = nativeIntersectionObserver; + delete window.__releaseVirtualizeObserverCallbacks; + delete window.__restoreVirtualizeIntersectionObserver; + delete window.__virtualizePendingObserverCallbacks; + }; + """); + } + + private bool IsScrolledToBottom(IJavaScriptExecutor js, IWebElement container) + => Math.Abs(GetScrollTop(js, container) - GetMaximumScrollTop(js, container)) < 2; + + private static long GetMaximumScrollTop(IJavaScriptExecutor js, IWebElement container) + => Convert.ToInt64(js.ExecuteScript( + "return arguments[0].scrollHeight - arguments[0].clientHeight;", container), CultureInfo.InvariantCulture); + // Types into and polls the sibling {id}-bound span until the bound model commits (needed on Server where @bind round-trips over SignalR). private void SetNumberInputAndWaitForBind(string elementId, int value) { diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor index 2cf5364d4716..48bc0d840ea6 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -60,6 +60,7 @@ + bound:@manualInitialIndex @@ -499,6 +500,18 @@ listLoaded = false; } + private async Task RefreshData() + { + if (virtualizeRef is null) + { + statusMessage = "Refresh failed: no virtualizer"; + return; + } + + await virtualizeRef.RefreshDataAsync(); + statusMessage = "Refreshed data"; + } + private async Task ScrollToTarget() { if (virtualizeRef == null) { scrollStatus = "Faulted: no virtualizeRef"; return; } From b310d95741c6c0a980a85997b97e575cfdc4b448 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 15 Sep 2026 13:37:34 +0200 Subject: [PATCH 2/7] Fix Virtualize End anchoring after initial provider load --- src/Components/Web.JS/src/Virtualize.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index 8135bcf0e4ac..a4904c2e2ff7 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -222,7 +222,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac || Math.abs(scrollElement.scrollTop + scrollElement.clientHeight - scrollElement.scrollHeight) < 2; const bottomTracking = { // Was the viewport at the bottom as of the last render? Drives the append re-pin. - wasAtBottomLastRender: false, + wasAtBottomLastRender: (anchorMode & 2) !== 0 && isViewportAtBottom(), // Has the viewport actually reached the bottom? Not set at mount, stays sticky across appends. reached: false, // Follow intent: true in End mode (or after a user-initiated End-key jump) until the user scrolls away. Drives the C# scroll-to-bottom path in End mode. @@ -393,6 +393,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac // End mode: pin new items into view if we're at the bottom now, or were and are still following. if ((anchorModeIs.end || bottomTracking.following) && (bottomTracking.wasAtBottomLastRender || bottomTracking.reached)) { + flushPendingStyleMutations(); scrollElement.scrollTop = scrollElement.scrollHeight; scrollActivity.ignoreNextScroll(); // Start convergence only when there are more items to load (spacerAfter > 0). From 8d54a3b139cbc93e10024a25d8f5a0fdc8691333 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 16 Sep 2026 14:19:13 +0200 Subject: [PATCH 3/7] Simplify Virtualize observer gating in E2E test. --- .../test/E2ETest/Tests/VirtualizationTest.cs | 83 +++++++------------ 1 file changed, 32 insertions(+), 51 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 5ad1c2bd6eea..b0153b5c0d60 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -5473,17 +5473,29 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() new SelectElement(Browser.Exists(By.Id("anchor-mode-select"))).SelectByValue("2"); Browser.Equal("2", () => Browser.Exists(By.Id("current-mode")).Text); - InstallVirtualizeIntersectionObserverGate(js); + js.ExecuteScript( + """ + window.__nativeIntersectionObserver = window.IntersectionObserver; + window.__virtualizeObserverCallbacks = []; + window.IntersectionObserver = class extends window.__nativeIntersectionObserver { + constructor(callback, options) { + super((entries, observer) => { + if (entries.some(entry => + entry.target?.hasAttribute?.('data-blazor-virtualize-reserved-height'))) { + window.__virtualizeObserverCallbacks.push(() => callback(entries, observer)); + } else { + callback(entries, observer); + } + }, options); + } + }; + """); + try { Browser.Exists(By.Id("reload-with-initial-index")).Click(); - Browser.True(() => Convert.ToBoolean(js.ExecuteScript( - """ - const spacer = document.querySelector( - '#scroll-container [data-blazor-virtualize-reserved-height]'); - return spacer?.style.flexShrink === '0' - && window.__virtualizePendingObserverCallbacks > 0; - """), CultureInfo.InvariantCulture)); + Browser.True(() => Convert.ToInt64(js.ExecuteScript( + "return window.__virtualizeObserverCallbacks.length;"), CultureInfo.InvariantCulture) > 0); Browser.Exists(By.Id("refresh-data")).Click(); Browser.Contains("Refreshed data", () => Browser.Exists(By.Id("status")).Text); @@ -5495,7 +5507,12 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() $"Expected the initial provider result to pin to the bottom, but scrollTop was " + $"{GetScrollTop(js, container)} of {GetMaximumScrollTop(js, container)}."); - js.ExecuteScript("window.__releaseVirtualizeObserverCallbacks();"); + js.ExecuteScript( + """ + for (const callback of window.__virtualizeObserverCallbacks.splice(0)) { + callback(); + } + """); Browser.True( () => GetBottomRenderedIndex(js) == 999 && IsScrolledToBottom(js, container), TimeSpan.FromSeconds(10), @@ -5504,51 +5521,15 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() } finally { - js.ExecuteScript("window.__restoreVirtualizeIntersectionObserver?.();"); + js.ExecuteScript( + """ + window.IntersectionObserver = window.__nativeIntersectionObserver; + delete window.__nativeIntersectionObserver; + delete window.__virtualizeObserverCallbacks; + """); } } - private static void InstallVirtualizeIntersectionObserverGate(IJavaScriptExecutor js) - { - js.ExecuteScript( - """ - const nativeIntersectionObserver = window.IntersectionObserver; - const pendingCallbacks = []; - window.__virtualizePendingObserverCallbacks = 0; - - window.IntersectionObserver = class extends nativeIntersectionObserver { - constructor(callback, options) { - super((entries, observer) => { - const containsVirtualizeSpacer = entries.some(entry => - entry.target?.hasAttribute?.('data-blazor-virtualize-reserved-height')); - if (containsVirtualizeSpacer) { - pendingCallbacks.push(() => callback(entries, observer)); - window.__virtualizePendingObserverCallbacks = pendingCallbacks.length; - return; - } - - callback(entries, observer); - }, options); - } - }; - - window.__releaseVirtualizeObserverCallbacks = () => { - for (const callback of pendingCallbacks.splice(0)) { - callback(); - } - window.__virtualizePendingObserverCallbacks = 0; - }; - - window.__restoreVirtualizeIntersectionObserver = () => { - window.__releaseVirtualizeObserverCallbacks(); - window.IntersectionObserver = nativeIntersectionObserver; - delete window.__releaseVirtualizeObserverCallbacks; - delete window.__restoreVirtualizeIntersectionObserver; - delete window.__virtualizePendingObserverCallbacks; - }; - """); - } - private bool IsScrolledToBottom(IJavaScriptExecutor js, IWebElement container) => Math.Abs(GetScrollTop(js, container) - GetMaximumScrollTop(js, container)) < 2; From 5d5459e25eb2ea432049b3227bc96dde0113c8ce Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 16 Sep 2026 16:27:49 +0200 Subject: [PATCH 4/7] Queue callbacks only when the flag is set. --- src/Components/test/E2ETest/Tests/VirtualizationTest.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index b0153b5c0d60..9c9110481b9c 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -5477,10 +5477,11 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() """ window.__nativeIntersectionObserver = window.IntersectionObserver; window.__virtualizeObserverCallbacks = []; + window.__holdVirtualizeObserverCallbacks = true; window.IntersectionObserver = class extends window.__nativeIntersectionObserver { constructor(callback, options) { super((entries, observer) => { - if (entries.some(entry => + if (window.__holdVirtualizeObserverCallbacks && entries.some(entry => entry.target?.hasAttribute?.('data-blazor-virtualize-reserved-height'))) { window.__virtualizeObserverCallbacks.push(() => callback(entries, observer)); } else { @@ -5509,6 +5510,7 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() js.ExecuteScript( """ + window.__holdVirtualizeObserverCallbacks = false; for (const callback of window.__virtualizeObserverCallbacks.splice(0)) { callback(); } @@ -5523,9 +5525,14 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() { js.ExecuteScript( """ + window.__holdVirtualizeObserverCallbacks = false; + for (const callback of window.__virtualizeObserverCallbacks.splice(0)) { + callback(); + } window.IntersectionObserver = window.__nativeIntersectionObserver; delete window.__nativeIntersectionObserver; delete window.__virtualizeObserverCallbacks; + delete window.__holdVirtualizeObserverCallbacks; """); } } From d587dfa7b3decfd804c18f41c08b141cfe3fd928 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 17 Sep 2026 12:50:03 +0200 Subject: [PATCH 5/7] Use throttling in a naturally flowing e2e test (gating is removed). --- .../test/E2ETest/Tests/VirtualizationTest.cs | 98 +++++++++---------- 1 file changed, 46 insertions(+), 52 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs index 9c9110481b9c..77293ffecb25 100644 --- a/src/Components/test/E2ETest/Tests/VirtualizationTest.cs +++ b/src/Components/test/E2ETest/Tests/VirtualizationTest.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.InternalTesting; using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Support.Extensions; using OpenQA.Selenium.Support.UI; @@ -5461,45 +5462,36 @@ private void MountAnchorModeForScrollToItem(bool useProvider, bool variableHeigh [Fact] public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() { - Browser.MountTestComponent(); - var container = Browser.Exists(By.Id("scroll-container")); - var js = (IJavaScriptExecutor)Browser; - Browser.True(() => GetElementCount(container, ".item") > 0); - - Browser.Exists(By.Id("unload-list")).Click(); - Browser.Exists(By.Id("list-not-loaded")); - Browser.Exists(By.Id("toggle-provider")).Click(); - Browser.Contains("Switched to ItemsProvider", () => Browser.Exists(By.Id("status")).Text); - new SelectElement(Browser.Exists(By.Id("anchor-mode-select"))).SelectByValue("2"); - Browser.Equal("2", () => Browser.Exists(By.Id("current-mode")).Text); - - js.ExecuteScript( - """ - window.__nativeIntersectionObserver = window.IntersectionObserver; - window.__virtualizeObserverCallbacks = []; - window.__holdVirtualizeObserverCallbacks = true; - window.IntersectionObserver = class extends window.__nativeIntersectionObserver { - constructor(callback, options) { - super((entries, observer) => { - if (window.__holdVirtualizeObserverCallbacks && entries.some(entry => - entry.target?.hasAttribute?.('data-blazor-virtualize-reserved-height'))) { - window.__virtualizeObserverCallbacks.push(() => callback(entries, observer)); - } else { - callback(entries, observer); - } - }, options); - } - }; - """); + var emulateServerLatency = _serverFixture.ExecutionMode == ExecutionMode.Server; + var chromeDriver = (ChromeDriver)Browser; + if (emulateServerLatency) + { + SetNetworkConditions(chromeDriver, latency: 400, throughput: 50_000); + Navigate(ServerPathBase); + } try { + Browser.MountTestComponent(); + var container = Browser.Exists(By.Id("scroll-container")); + var js = (IJavaScriptExecutor)Browser; + Browser.True(() => GetElementCount(container, ".item") > 0); + + Browser.Exists(By.Id("unload-list")).Click(); + Browser.Exists(By.Id("list-not-loaded")); + Browser.Exists(By.Id("toggle-provider")).Click(); + Browser.Contains("Switched to ItemsProvider", () => Browser.Exists(By.Id("status")).Text); + new SelectElement(Browser.Exists(By.Id("anchor-mode-select"))).SelectByValue("2"); + Browser.Equal("2", () => Browser.Exists(By.Id("current-mode")).Text); + Browser.Exists(By.Id("toggle-provider-gate")).Click(); + Browser.Contains("Provider gate: On", () => Browser.Exists(By.Id("status")).Text); + Browser.Exists(By.Id("reload-with-initial-index")).Click(); - Browser.True(() => Convert.ToInt64(js.ExecuteScript( - "return window.__virtualizeObserverCallbacks.length;"), CultureInfo.InvariantCulture) > 0); + Browser.True(() => GetProviderCallIndex(js) == 1); + Browser.Contains("p1-enter", () => GetProviderEvents(js)); - Browser.Exists(By.Id("refresh-data")).Click(); - Browser.Contains("Refreshed data", () => Browser.Exists(By.Id("status")).Text); + Browser.Exists(By.Id("release-provider-gate")).Click(); + Browser.Contains("p1-return", () => GetProviderEvents(js)); Browser.True(() => GetMaximumScrollTop(js, container) > 0); Browser.True( @@ -5508,13 +5500,9 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() $"Expected the initial provider result to pin to the bottom, but scrollTop was " + $"{GetScrollTop(js, container)} of {GetMaximumScrollTop(js, container)}."); - js.ExecuteScript( - """ - window.__holdVirtualizeObserverCallbacks = false; - for (const callback of window.__virtualizeObserverCallbacks.splice(0)) { - callback(); - } - """); + Browser.True(() => GetProviderCallIndex(js) == 2); + Browser.Exists(By.Id("release-provider-gate")).Click(); + Browser.Contains("p2-return", () => GetProviderEvents(js)); Browser.True( () => GetBottomRenderedIndex(js) == 999 && IsScrolledToBottom(js, container), TimeSpan.FromSeconds(10), @@ -5523,20 +5511,26 @@ public void AnchorMode_End_InitialItemsProviderLoad_PinsToBottom() } finally { - js.ExecuteScript( - """ - window.__holdVirtualizeObserverCallbacks = false; - for (const callback of window.__virtualizeObserverCallbacks.splice(0)) { - callback(); - } - window.IntersectionObserver = window.__nativeIntersectionObserver; - delete window.__nativeIntersectionObserver; - delete window.__virtualizeObserverCallbacks; - delete window.__holdVirtualizeObserverCallbacks; - """); + if (emulateServerLatency) + { + SetNetworkConditions(chromeDriver, latency: 0, throughput: -1); + chromeDriver.ExecuteCdpCommand("Network.disable", new Dictionary()); + } } } + private static void SetNetworkConditions(ChromeDriver chromeDriver, int latency, int throughput) + { + chromeDriver.ExecuteCdpCommand("Network.enable", new Dictionary()); + chromeDriver.ExecuteCdpCommand("Network.emulateNetworkConditions", new Dictionary + { + ["offline"] = false, + ["latency"] = latency, + ["downloadThroughput"] = throughput, + ["uploadThroughput"] = throughput, + }); + } + private bool IsScrolledToBottom(IJavaScriptExecutor js, IWebElement container) => Math.Abs(GetScrollTop(js, container) - GetMaximumScrollTop(js, container)) < 2; From 6bdfde9acd825453b4dcc3a2ad5354fcd4d3491d Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 17 Sep 2026 13:14:35 +0200 Subject: [PATCH 6/7] Missing change for commit https://github.com/dotnet/aspnetcore/pull/69310/commits/d587dfa7b3decfd804c18f41c08b141cfe3fd928. --- .../BasicTestApp/VirtualizationAnchorMode.razor | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor index 48bc0d840ea6..2cf5364d4716 100644 --- a/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor +++ b/src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor @@ -60,7 +60,6 @@ - bound:@manualInitialIndex @@ -500,18 +499,6 @@ listLoaded = false; } - private async Task RefreshData() - { - if (virtualizeRef is null) - { - statusMessage = "Refresh failed: no virtualizer"; - return; - } - - await virtualizeRef.RefreshDataAsync(); - statusMessage = "Refreshed data"; - } - private async Task ScrollToTarget() { if (virtualizeRef == null) { scrollStatus = "Faulted: no virtualizeRef"; return; } From 850064cf6dbf144e53e77ec0bd13c36a53056fe3 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 17 Sep 2026 13:25:38 +0200 Subject: [PATCH 7/7] Update the changes to make the new tests pass. --- src/Components/Web.JS/src/Virtualize.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Components/Web.JS/src/Virtualize.ts b/src/Components/Web.JS/src/Virtualize.ts index a4904c2e2ff7..24da2f68d909 100644 --- a/src/Components/Web.JS/src/Virtualize.ts +++ b/src/Components/Web.JS/src/Virtualize.ts @@ -222,7 +222,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac || Math.abs(scrollElement.scrollTop + scrollElement.clientHeight - scrollElement.scrollHeight) < 2; const bottomTracking = { // Was the viewport at the bottom as of the last render? Drives the append re-pin. - wasAtBottomLastRender: (anchorMode & 2) !== 0 && isViewportAtBottom(), + wasAtBottomLastRender: false, // Has the viewport actually reached the bottom? Not set at mount, stays sticky across appends. reached: false, // Follow intent: true in End mode (or after a user-initiated End-key jump) until the user scrolls away. Drives the C# scroll-to-bottom path in End mode. @@ -392,7 +392,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac } // End mode: pin new items into view if we're at the bottom now, or were and are still following. - if ((anchorModeIs.end || bottomTracking.following) && (bottomTracking.wasAtBottomLastRender || bottomTracking.reached)) { + if (bottomTracking.following + || (anchorModeIs.end && (bottomTracking.wasAtBottomLastRender || bottomTracking.reached))) { flushPendingStyleMutations(); scrollElement.scrollTop = scrollElement.scrollHeight; scrollActivity.ignoreNextScroll(); @@ -717,7 +718,12 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac scrollElement, startConvergenceObserving, isFollowingBottom: () => bottomTracking.following, - setAnchorMode: (mode: number) => { anchorMode = mode; bottomTracking.following = (mode & 2) !== 0; bottomTracking.reached = isViewportAtBottom(); }, + setAnchorMode: (mode: number) => { + anchorMode = mode; + const atBottom = isViewportAtBottom(); + bottomTracking.following = (mode & 2) !== 0 && atBottom; + bottomTracking.reached = atBottom; + }, restoreAnchor: restoreAnchorForShift, alignToItem: alignToItemAt, beginProgrammaticScroll: beginProgrammaticScroll,