Skip to content

Fix InitialItemIndex is intermittently ignored on fresh page load - #68114

Merged
ilonatommy merged 26 commits into
mainfrom
fix-68099
Aug 13, 2026
Merged

Fix InitialItemIndex is intermittently ignored on fresh page load #68114
ilonatommy merged 26 commits into
mainfrom
fix-68099

Conversation

@ilonatommy

@ilonatommy ilonatommy commented Jul 30, 2026

Copy link
Copy Markdown
Member

Fixes #68099.

It's not visible with fast connection but with throttling the reproduction is stable, videos for reference.

Before the fix:

InitialItemIndex.-.Dan.race.mp4

After the fix:

InitialItemIndex.-.Dan.race.fixed.mp4

Problem

InitialItemIndex was ccasionally ignored on a fresh page load. The newly-created spacer IntersectionObservers fired an "initial" callback at scrollTop == 0 before the component's own ScrollToItemAsync(InitialItemIndex) round trip completes, and .NET couldn't tell that stale startup callback apart from a real user scroll. So it cancelled the pending initial scroll and reset the window to index 0. This race race reproduces reliably with latency, as above, on the first cold request after app starts.

Fixes

  • Remove the race:
    JS now tells .NET why a spacer became visible (SpacerVisibilityReason: UserScroll/ProgrammaticScroll/ViewportFill) instead of .NET trying to infer intent from timing via a suppression flag. A stale startup callback is a ViewportFilland is ignored while the initial alignment is in flight. A real scroll is always classified asUserScroll` and always honored. This removes the race.

(An earlier revision of this PR suppressed the observer's initial callback via a suppressInitialSpacerCallbacks flag passed into JS's init() . That approach has since been replaced by the reason-based design above, based on review feedback see ).

  • First scroll after InitialItemIndex could land on the wrong item:
    After InitialItemIndex jumps to a distant index (distant == changes spacer sizes considerably), the rendered window is positioned far from both spacers. No spacer-visibility callback fires to measure real item height before the user's first scroll. The item-size estimate then stays at its default until that first scroll, where a single large recalibration (e.g. real rendered height including borders/padding vs. the default ItemSize) gets applied across a huge already-rendered span. That was producing a large one-time miscalculation of the window position, visible as the list jumping to the wrong item, e.g. 515 -> 420.
    Fix:
    C# needed an update from JS about the new content distibution after the jump-to-item. New SpacerVisibilityReason was introduced for that: RenderedContentMeasurement and the callback to C# is triggered after the initial alignment lands.

  • First render dispatching a spurious spacer callback (Virtualize first render has upper spacer #64029)
    On the first render for id=X, X!=0, where both spacers are simultaneously visible, both callback fired. The existing dedup (skip one spacer when its height is 0) wasn't sufficient to prevent this, so the conflicting "after" callback would get processed too.
    Fix:
    Explicitly detect when both spacers intersect and reporting only the "before" spacer's callback in that case. Covered by the new InitialRender_DispatchesSingleSpacerCallback test: 0332c35.

  • Why the tests are not in the component we normally used for InitialItemIndex tests:
    The test had to be added to a component that contains "to interactivity" transition so I reused the VirtualizeAnchorMode component in VirtualizationTransitionToInteractivity.razor.

@ilonatommy ilonatommy added this to the 11.0-rc1 milestone Jul 30, 2026
@ilonatommy ilonatommy self-assigned this Jul 30, 2026
Copilot AI review requested due to automatic review settings July 30, 2026 14:04
@ilonatommy
ilonatommy requested a review from a team as a code owner July 30, 2026 14:04
@ilonatommy ilonatommy added area-blazor Includes: Blazor, Razor Components feature-blazor-virtualization This issue is related to the Blazor Virtualize component labels Jul 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a startup race in the Virtualize<TItem> initial-scroll path where the spacers’ IntersectionObserver initial callback can run before the initial programmatic alignment, intermittently resetting the window back to index 0 on fresh loads (especially with prerendering + interop latency).

Changes:

  • Adds a new suppressInitialSpacerCallbacks flag to the JS Virtualize.init interop call so JS can ignore the observers’ initial callback until the component’s first programmatic-scroll decision is made.
  • Plumbs the new flag from VirtualizeVirtualizeJsInteropVirtualize.ts, enabling suppression when InitialItemIndex > 0.
  • Adds coverage: a unit test verifying the init-argument roundtrip, plus an E2E test that validates InitialItemIndex remains applied after transitioning to interactive server mode.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Components/Web/test/Virtualization/VirtualizeTest.cs Adds a unit test verifying InitialItemIndex controls initial spacer-callback suppression passed to JS init.
src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs Extends InitializeAsync to pass the suppression flag to Blazor._internal.Virtualize.init.
src/Components/Web/src/Virtualization/Virtualize.cs Enables initial suppression when InitialItemIndex > 0 during first render initialization.
src/Components/Web.JS/src/Virtualize.ts Adds suppressInitialSpacerCallbacks param and initializes suppressSpacerCallbacks from it to ignore the initial IO callback.
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Interactivity/VirtualizationTransitionToInteractivity.razor Adds a query-switched path to exercise VirtualizationAnchorMode during transition-to-interactivity.
src/Components/test/testassets/BasicTestApp/VirtualizationAnchorMode.razor Makes InitialItemIndex a component parameter and wires it through to the Virtualize component and UI.
src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs Adds an E2E assertion that InitialItemIndex is applied after prerender → interactive server load.

Comment thread src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs Outdated

@oroztocil oroztocil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks correct but also as a fix for the symptom rather than the cause.

Idea (maybe misguided): add a bool userInitiated argument to OnSpacerBeforeVisible and OnSpacerAfterVisible that passes information if they were initiated do to user scroll or programmatic scroll. The JS side should be able to recognize/track that. ShouldSuppressSpacerCallback can use then this information so it does not have to infer user scroll vs programmatic scroll based on timing (which introduces the race, if I understand things correctly) and the JS suppression added in this PR would not be needed.

Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/test/E2ETest/ServerRenderingTests/VirtualizationRenderModesTest.cs Outdated
@ilonatommy

Copy link
Copy Markdown
Member Author

Looks correct but also as a fix for the symptom rather than the cause.

Idea (maybe misguided): add a bool userInitiated argument to OnSpacerBeforeVisible and OnSpacerAfterVisible that passes information if they were initiated do to user scroll or programmatic scroll. The JS side should be able to recognize/track that. ShouldSuppressSpacerCallback can use then this information so it does not have to infer user scroll vs programmatic scroll based on timing (which introduces the race, if I understand things correctly) and the JS suppression added in this PR would not be needed.

Thanks, that does make sense. The mechanism where c# had to detect the reason for the new callback was caused by conservative approach towards existing JSInterop. In the current PR the only fix option was to change the communication methods so the original constraint doesn't apply. I will take a look at the idea in a broader perspective.

ilonatommy added a commit that referenced this pull request Aug 6, 2026
…67933)

When the target list underfills the viewport (small overscan / large container), a bottom-spacer ViewportFill callback grows the window capacity to cover the viewport while keeping itemsBefore pinned to the aligned target. Builds on the spacer-visibility reason enum from #68099 (PR #68114).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ilonatommy and others added 2 commits August 6, 2026 12:05
The spacer-visibility redesign removed the boolean suppressSpacerCallbacks mechanism (replaced by the scrollActivity/SpacerVisibilityReason enum). Merging #68064 reintroduced a write to that now-undeclared variable, breaking the TypeScript build (TS2304). The write had no remaining reader, so removing it is behavior-preserving; ServerVirtualizationTest.AnchorMode_End_PrependAtTop_ViewportStaysStable passes 28/28 across 7 runs (both useItemsProvider cases).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ilonatommy added a commit that referenced this pull request Aug 6, 2026
…67933)

When the target list underfills the viewport (small overscan / large container), a bottom-spacer ViewportFill callback grows the window capacity to cover the viewport while keeping itemsBefore pinned to the aligned target. Builds on the spacer-visibility reason enum from #68099 (PR #68114).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ilonatommy
ilonatommy requested review from Copilot and oroztocil August 6, 2026 15:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Note

This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.

@ilonatommy
ilonatommy requested a review from Copilot August 6, 2026 16:48
@PureWeen

Copy link
Copy Markdown
Member

Would it make more sense to capture the internal contract at the two implementation points instead?

I'm generally against encouraging AI to add any comments. I would rather encourage it to read the code and propose:

  • renames
  • introducing named methods even if DRY doesn't apply
  • maybe support single responsibility concept
  • introducing named variables

to explain what the code does. In this case, Abort() is called now in one place only, CancelInFlightScrollForUserInteraction(), which is itself only invoked from the UserScroll case. So "a UserScroll-classified callback ends the pin" is already discoverable without any change. The 2nd case makes more sense to have a comment as it's not the clear and rename wouldn't solve it. I suspect it's mostly because this PR is a partial fix and we're planning a follow-up that will close the loop.

Agreed!

I've updated the reviewer guidance and evals to prefer renames, named methods or variables, and smaller responsibilities first, keep internal mechanics out of public API docs, and only suggest comments for those durable nonlocal constraints. Thanks for pushing on this.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/Components/Web/src/Virtualization/Virtualize.cs:632

  • _initialIndex is set to Pending for InitialItemIndex > 0, but nothing ever transitions it to Completed unless the user scrolls. That leaves the component permanently treating spacer callbacks as "initial alignment in flight" (e.g., ignoring ViewportFill in OnAfterSpacerVisible), which can prevent normal redistribution until the first user interaction.
    private void RerenderSpacersIfItemSizeChanged(float previousItemSize)
    {
        if (_itemSize != previousItemSize)
        {
            StateHasChanged();

src/Components/Web.JS/src/Virtualize.ts:630

  • ScrollToOptions.behavior does not standardly support the value 'instant' (supported values are typically 'auto' or 'smooth'). Using a non-standard value risks inconsistent behavior across browsers; use 'auto' for an immediate scroll.
    if (Math.abs(delta) > 0.5) {
      beginAlign();
      pendingJumpToStart = false;
      pendingJumpToEnd = false;
      scrollElement.scrollTo({ top: scrollElement.scrollTop + delta, behavior: 'instant' });
    }

src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs:37

  • reason comes from JS into a [JSInvokable] method and is cast directly to SpacerVisibilityReason. If an unexpected value is supplied (e.g., mismatched JS/C# versions or a malformed call), the component will treat it as an unknown enum value and execute redistribution logic under an unintended reason. Validate/clamp the value before dispatching to the owner.
    [JSInvokable]
    public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, int reason)
    {
        _owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, (SpacerVisibilityReason)reason);
    }

src/Components/Web/src/Virtualization/Virtualize.cs:506

  • When InitialItemIndex > 0, _initialIndex is set to Pending before ScrollToItemAsync. If the target can't be aligned (e.g., the provider returns 0 items / _lastRenderedItemCount stays 0), no RenderedContentMeasurement arrives to complete the state, so _initialIndex can remain Pending indefinitely and suppress normal spacer redistribution.

This issue also appears on line 628 of the same file.

            if (InitialItemIndex > 0)
            {
                _initialIndex.BeginPending(_itemSize);
                await ScrollToItemAsync(InitialItemIndex);
            }

@ilonatommy

ilonatommy commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

src/Components/Web.JS/src/Virtualize.ts:630

  • ScrollToOptions.behavior does not standardly support the value 'instant' (supported values are typically 'auto' or 'smooth'). Using a non-standard value risks inconsistent behavior across browsers; use 'auto' for an immediate scroll.

Interesting review quality: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo#parameters
I will retry with Balanced.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/Components/Web/src/Virtualization/Virtualize.cs:759

  • spacerSeparation <= 0 does not reject NaN, so a NaN callback now falls through and permanently sets _totalMeasuredHeight to NaN. The previous positive-value check skipped NaN; reject non-finite measurements before accumulating them.
        if (_lastRenderedItemCount <= 0 || _lastRenderedPlaceholderCount > 0 || spacerSeparation <= 0)

Comment thread src/Components/Web.JS/src/Virtualize.ts
@ilonatommy

Copy link
Copy Markdown
Member Author

src/Components/Web/src/Virtualization/Virtualize.cs:759

  • spacerSeparation <= 0 does not reject NaN, so a NaN callback now falls through and permanently sets _totalMeasuredHeight to NaN. The previous positive-value check skipped NaN; reject non-finite measurements before accumulating them.
        if (_lastRenderedItemCount <= 0 || _lastRenderedPlaceholderCount > 0 || spacerSeparation <= 0)

NaN from JS doesn't reach server code, interop would transforms it to null so there's not scenario where it could happen, even if we somehow forced JS to get infinite spacer measurement.

@kotlarmilos kotlarmilos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with one comment below

Comment thread src/Components/Web/src/Virtualization/Virtualize.cs
Comment thread src/Components/Web.JS/src/Virtualize.ts
Comment thread src/Components/Web/src/Virtualization/Virtualize.cs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components feature-blazor-virtualization This issue is related to the Blazor Virtualize component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

6 participants