Is there an existing issue for this?
Related but distinct: #67933 (small InitialIndex values deterministically don't fill the viewport until the first scroll). This issue is about the initial scroll being discarded entirely and the list resetting to index 0, intermittently, because of a startup race.
Describe the bug
Virtualize<TItem>.InitialItemIndex (shipped as InitialIndex in .NET 11 Preview 6, renamed in #67312) is intermittently ignored on a fresh page load. The list renders at index 0 with scrollTop == 0 instead of at the requested item.
This is a startup race, not a configuration problem. Prerendering positions the window correctly, but the interactive pass then resets it back to index 0.
Root cause
-
Prerender positions the window correctly — the "before" spacer is emitted with data-blazor-virtualize-reserved-height="24250" and the first rendered item is #485.
-
OnAfterRenderAsync(firstRender: true) calls JS init(), which creates the spacer IntersectionObservers.
-
OnAfterRenderAsync sets _initialScrollApplied = true before await ScrollToItemAsync(InitialItemIndex), which closes the initial-scroll guard at the top of ShouldSuppressSpacerCallback():
// Before the initial ScrollToItemAsync runs, ignore IO callbacks: at scrollTop=0
// they would compute itemsBefore=0 and overwrite the pre-positioned window.
if (!_initialScrollApplied && InitialItemIndex > 0)
{
return true;
}
-
The newly created observers fire their initial callback right away, while scrollTop == 0, dispatching OnSpacerBeforeVisible(spacerSize: 0, ...) back to .NET.
-
That callback now lands in the "this must be a real user scroll" branch of ShouldSuppressSpacerCallback():
// Before our render commits, IO callbacks reflect a real user scroll: cancel the
// programmatic scroll and the in-flight provider call so the user's window wins.
_currentScrollCts.Cancel();
_currentScrollCts = null;
_refreshCts?.Cancel();
return false;
The initial scroll is cancelled, the window is recomputed as itemsBefore = 0, and alignToItem is never called.
The JS-side suppression (beginProgrammaticScroll → suppressSpacerCallbacks = true) cannot prevent this, because it arrives as a separate interop round trip after the observer callback has already been dispatched.
Relevant source:
src/Components/Web/src/Virtualization/Virtualize.cs — OnAfterRenderAsync initial-scroll block and ShouldSuppressSpacerCallback()
src/Components/Web.JS/src/Virtualize.ts — init(), beginProgrammaticScrollSuppression(), and the OnSpacerBeforeVisible dispatch
Captured interop trace
Failing load (instrumented Blazor._internal.Virtualize):
t=1348 net->js init spacerHeight=24250, firstItem=null
t=1350 net->js beginProgrammaticScroll spacerHeight=24250
t=1369 net->js refreshObservers spacerHeight=0, firstItem="#0 — Product 0" <-- window reset
(alignToItem never called)
Passing load, same instrumentation:
t=58 net->js init spacerHeight=24250
t=60 net->js beginProgrammaticScroll
t=66 net->js refreshObservers firstItem="#485 — Product 485"
t=66 net->js alignToItem(15) scrollTop=25010
Why it looks like "only on refresh"
It is a race between the observers' first callback and the alignToItem round trip. Warm on localhost, alignToItem wins consistently (8/8 loads succeeded). On the first cold request after app start, or with emulated network latency, the observer callback wins and the initial scroll is discarded. A full page refresh creates a new circuit with cold interop, so it loses the race far more often than enhanced navigation within an already-connected circuit.
Expected Behavior
The list opens scrolled to InitialItemIndex on every load, regardless of interop latency. In the repro below, the scroll container should always end up with a non-zero scrollTop (~25000 at the default ItemSize of 50) and item #500 aligned to the top of the viewport.
Steps To Reproduce
-
Create a Blazor Web App with prerendering enabled (default):
dotnet new blazor -o VirtualizeRepro --interactivity Server
cd VirtualizeRepro
-
Add Components/Pages/VirtualizeScroll.razor:
@page "/virtualize-scroll"
@rendermode InteractiveServer
<div id="scroll-container" style="height: 500px; overflow-y: auto;">
<Virtualize TItem="Product" Items="products" InitialItemIndex="500">
<div class="card mb-2" style="min-height: 50px;">#@context.Id — @context.Name</div>
</Virtualize>
</div>
@code {
List<Product> products = Enumerable.Range(0, 10_000)
.Select(i => new Product(i, $"Product {i}"))
.ToList();
record Product(int Id, string Name);
}
On .NET 11 Preview 6 the parameter is named InitialIndex rather than InitialItemIndex.
-
dotnet run, then navigate to /virtualize-scroll for the first time after the app starts. The cold start is when I first hit this — the first load reproduced it, and every subsequent warm load passed.
-
To reproduce repeatedly without restarting the app, open DevTools → Network, add a custom throttling profile with 150–250 ms latency (no throughput limit), then reload /virtualize-scroll several times.
-
After each load, check the scroll container:
const c = document.querySelector('#scroll-container');
console.log(c.scrollTop, c.querySelector('.card').textContent.trim());
Actual: 0 "#0 — Product 0" — the list is at the top and the initial scroll was silently dropped.
Expected: a non-zero scrollTop (~25000) with the first rendered item near #485 (overscan) and #500 aligned to the top of the viewport.
Observed roughly 1 in 4 loads at 150 ms emulated latency, and on the first cold load with no latency emulation. Reloading enough times (or removing the latency) makes it pass again, which is what makes it look flaky.
Exceptions (if any)
None — the scroll is silently dropped, with no console errors or server-side exceptions.
.NET Version
11.0.100-preview.6.26359.118
Anything else?
The same logic is present on main (introduced in d494c2436a, renamed in #67312), so this is not specific to Preview 6.
Suggested fix direction: suppress the observers' initial callback on the JS side until the component's first programmatic-scroll decision has been made — for example, pass a flag to init() when InitialItemIndex > 0 so suppressSpacerCallbacks starts out true, instead of relying on a later beginProgrammaticScroll round trip. A server-only guard is not sufficient, because .NET genuinely cannot distinguish the stale startup callback from a real user scroll.
Is there an existing issue for this?
Related but distinct: #67933 (small
InitialIndexvalues deterministically don't fill the viewport until the first scroll). This issue is about the initial scroll being discarded entirely and the list resetting to index 0, intermittently, because of a startup race.Describe the bug
Virtualize<TItem>.InitialItemIndex(shipped asInitialIndexin .NET 11 Preview 6, renamed in #67312) is intermittently ignored on a fresh page load. The list renders at index 0 withscrollTop == 0instead of at the requested item.This is a startup race, not a configuration problem. Prerendering positions the window correctly, but the interactive pass then resets it back to index 0.
Root cause
Prerender positions the window correctly — the "before" spacer is emitted with
data-blazor-virtualize-reserved-height="24250"and the first rendered item is#485.OnAfterRenderAsync(firstRender: true)calls JSinit(), which creates the spacerIntersectionObservers.OnAfterRenderAsyncsets_initialScrollApplied = truebeforeawait ScrollToItemAsync(InitialItemIndex), which closes the initial-scroll guard at the top ofShouldSuppressSpacerCallback():The newly created observers fire their initial callback right away, while
scrollTop == 0, dispatchingOnSpacerBeforeVisible(spacerSize: 0, ...)back to .NET.That callback now lands in the "this must be a real user scroll" branch of
ShouldSuppressSpacerCallback():The initial scroll is cancelled, the window is recomputed as
itemsBefore = 0, andalignToItemis never called.The JS-side suppression (
beginProgrammaticScroll→suppressSpacerCallbacks = true) cannot prevent this, because it arrives as a separate interop round trip after the observer callback has already been dispatched.Relevant source:
src/Components/Web/src/Virtualization/Virtualize.cs—OnAfterRenderAsyncinitial-scroll block andShouldSuppressSpacerCallback()src/Components/Web.JS/src/Virtualize.ts—init(),beginProgrammaticScrollSuppression(), and theOnSpacerBeforeVisibledispatchCaptured interop trace
Failing load (instrumented
Blazor._internal.Virtualize):Passing load, same instrumentation:
Why it looks like "only on refresh"
It is a race between the observers' first callback and the
alignToItemround trip. Warm on localhost,alignToItemwins consistently (8/8 loads succeeded). On the first cold request after app start, or with emulated network latency, the observer callback wins and the initial scroll is discarded. A full page refresh creates a new circuit with cold interop, so it loses the race far more often than enhanced navigation within an already-connected circuit.Expected Behavior
The list opens scrolled to
InitialItemIndexon every load, regardless of interop latency. In the repro below, the scroll container should always end up with a non-zeroscrollTop(~25000 at the defaultItemSizeof 50) and item#500aligned to the top of the viewport.Steps To Reproduce
Create a Blazor Web App with prerendering enabled (default):
Add
Components/Pages/VirtualizeScroll.razor:On .NET 11 Preview 6 the parameter is named
InitialIndexrather thanInitialItemIndex.dotnet run, then navigate to/virtualize-scrollfor the first time after the app starts. The cold start is when I first hit this — the first load reproduced it, and every subsequent warm load passed.To reproduce repeatedly without restarting the app, open DevTools → Network, add a custom throttling profile with 150–250 ms latency (no throughput limit), then reload
/virtualize-scrollseveral times.After each load, check the scroll container:
Actual:
0 "#0 — Product 0"— the list is at the top and the initial scroll was silently dropped.Expected: a non-zero
scrollTop(~25000) with the first rendered item near#485(overscan) and#500aligned to the top of the viewport.Observed roughly 1 in 4 loads at 150 ms emulated latency, and on the first cold load with no latency emulation. Reloading enough times (or removing the latency) makes it pass again, which is what makes it look flaky.
Exceptions (if any)
None — the scroll is silently dropped, with no console errors or server-side exceptions.
.NET Version
11.0.100-preview.6.26359.118
Anything else?
The same logic is present on
main(introduced ind494c2436a, renamed in #67312), so this is not specific to Preview 6.Suggested fix direction: suppress the observers' initial callback on the JS side until the component's first programmatic-scroll decision has been made — for example, pass a flag to
init()whenInitialItemIndex > 0sosuppressSpacerCallbacksstarts outtrue, instead of relying on a laterbeginProgrammaticScrollround trip. A server-only guard is not sufficient, because .NET genuinely cannot distinguish the stale startup callback from a real user scroll.