Skip to content

Virtualize: InitialItemIndex is intermittently ignored on fresh page load (startup race with spacer IntersectionObserver callback) #68099

Description

@danroth27

Is there an existing issue for this?

  • I have searched the existing issues

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

  1. Prerender positions the window correctly — the "before" spacer is emitted with data-blazor-virtualize-reserved-height="24250" and the first rendered item is #485.

  2. OnAfterRenderAsync(firstRender: true) calls JS init(), which creates the spacer IntersectionObservers.

  3. 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;
    }
  4. The newly created observers fire their initial callback right away, while scrollTop == 0, dispatching OnSpacerBeforeVisible(spacerSize: 0, ...) back to .NET.

  5. 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 (beginProgrammaticScrollsuppressSpacerCallbacks = 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.csOnAfterRenderAsync initial-scroll block and ShouldSuppressSpacerCallback()
  • src/Components/Web.JS/src/Virtualize.tsinit(), 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

  1. Create a Blazor Web App with prerendering enabled (default):

    dotnet new blazor -o VirtualizeRepro --interactivity Server
    cd VirtualizeRepro
    
  2. 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.

  3. 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.

  4. 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.

  5. 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.

Metadata

Metadata

Assignees

Labels

area-blazorIncludes: Blazor, Razor Components

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions