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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
86 changes: 86 additions & 0 deletions src/Components/test/E2ETest/Tests/VirtualizationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5458,6 +5458,92 @@ 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<VirtualizationAnchorMode>();
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);
}
};
""");

try
{
Browser.Exists(By.Id("reload-with-initial-index")).Click();
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);
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.__holdVirtualizeObserverCallbacks = false;
for (const callback of window.__virtualizeObserverCallbacks.splice(0)) {
callback();
}
""");
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.__holdVirtualizeObserverCallbacks = false;
for (const callback of window.__virtualizeObserverCallbacks.splice(0)) {
callback();
}
window.IntersectionObserver = window.__nativeIntersectionObserver;
delete window.__nativeIntersectionObserver;
delete window.__virtualizeObserverCallbacks;
delete window.__holdVirtualizeObserverCallbacks;
""");
}
}

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 <input type=number @bind=...> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
</label>
<button id="reload-with-initial-index" @onclick="ReloadWithInitialIndex">Load list with Initial Index</button>
<button id="unload-list" @onclick="UnloadList">Unload list</button>
<button id="refresh-data" @onclick="RefreshData">Refresh data</button>
<span id="manual-initial-index-bound" data-value="@manualInitialIndex" style="margin-left: 8px; color:#888;">bound:@manualInitialIndex</span>
</div>

Expand Down Expand Up @@ -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; }
Expand Down
Loading