Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c278450
Fix the spacer callback and item-alignment call race on initial trans…
ilonatommy Jul 30, 2026
e60afe5
Align tolerance with the most commonly used in tests.
ilonatommy Jul 30, 2026
0af1bd2
JS hook that assures test fails on regression.
ilonatommy Jul 31, 2026
92a340e
Feedback: JS can inform C# about the reason why spacer is visible.
ilonatommy Aug 6, 2026
8b8fc77
Merge branch 'main' into fix-68099
ilonatommy Aug 6, 2026
ba746b7
Limit the docs for internal class.
ilonatommy Aug 6, 2026
211f8b4
Remove orphaned suppressSpacerCallbacks write left by #68064 merge
ilonatommy Aug 6, 2026
6750257
Feedback: Add info about the cause when callback doesn't fire.
ilonatommy Aug 6, 2026
dbf91e9
Initialindex and ScrollToItem should test in-memory items as well.
ilonatommy Aug 7, 2026
0f9bb04
Update src/Components/Web.JS/src/Virtualize.ts
ilonatommy Aug 7, 2026
c688c09
Fix in memory tests: move the data window after refreshing data.
ilonatommy Aug 7, 2026
aa751ae
Test further actions after the initial load.
ilonatommy Aug 7, 2026
0332c35
Tighten test for https://github.com/dotnet/aspnetcore/issues/64029.
ilonatommy Aug 10, 2026
f898550
Fix both spacers visible case tested by `InitialRender_DispatchesSing…
ilonatommy Aug 10, 2026
11d988d
Fix jumps. Leave the viewport fill for the follow up PR.
ilonatommy Aug 10, 2026
22c3059
Clean tests: id=1 and id=5 are the same case. Move jumping and expans…
ilonatommy Aug 10, 2026
c239d69
Fix InitialItemIndex jump landing at wrong item on first scroll.
ilonatommy Aug 10, 2026
b172f57
Fix Virtualize programmatic scroll-to-bottom convergence and make sur…
ilonatommy Aug 10, 2026
5480a17
Tighten the test.
ilonatommy Aug 10, 2026
0dc8dc8
Fix `ScrollToItems` after `IntiailItemIndex!=0`: Pending state for In…
ilonatommy Aug 11, 2026
847a030
`ScrollToItemAsync` respects cancellation token.
ilonatommy Aug 11, 2026
6039b1e
Tests use real scrolling, not JS symulation of scrolling.
ilonatommy Aug 13, 2026
1f618c8
`scroll` even is triggered by both user scoll and self scroll. Listen…
ilonatommy Aug 13, 2026
6c67d88
Feedback: Avoid repeated programmatic scroll events being treated as …
ilonatommy Aug 13, 2026
01cac72
More tests need real wheel srolling.
ilonatommy Aug 13, 2026
89f4c00
More tests need correcting the scroll action.
ilonatommy Aug 13, 2026
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
16 changes: 11 additions & 5 deletions src/Components/Web.JS/src/Virtualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ function getScaleFactor(spacerBefore: HTMLElement, spacerAfter: HTMLElement): nu
return (Number.isFinite(scale) && scale > 0) ? scale : 1;
}

function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spacerAfter: HTMLElement, anchorMode = 1, rootMargin = 50): void {
function init(
dotNetHelper: DotNet.DotNetObject,
spacerBefore: HTMLElement,
spacerAfter: HTMLElement,
anchorMode = 1,
suppressInitialSpacerCallbacks = false,
rootMargin = 50,
): void {
// If the component was disposed before the JS interop call completed, the element references may be null
// or the elements may have been disconnected from the DOM. Return early to avoid errors.
if (!spacerBefore || !spacerAfter || !spacerBefore.isConnected || !spacerAfter.isConnected) {
Expand Down Expand Up @@ -138,6 +145,8 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
mutationObserver.observe(spacerBefore, spacerObserverOptions);
mutationObserver.observe(spacerAfter, spacerObserverOptions);

let suppressSpacerCallbacks = suppressInitialSpacerCallbacks;
Comment thread
ilonatommy marked this conversation as resolved.
Resolved

const intersectionObserver = new IntersectionObserver(intersectionCallback, {
root: scrollContainer,
rootMargin: `${rootMargin}px`,
Expand Down Expand Up @@ -176,8 +185,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
const anchoredItems: Map<Element, number> = new Map();
let scrollTriggeredRender = false;

// After anchor restore, suppress spacer IO callbacks until the next user scroll.
let suppressSpacerCallbacks = false;
// After anchor restore or programmatic alignment, suppress spacer IO callbacks until the next user scroll.
Comment thread
ilonatommy marked this conversation as resolved.
Resolved
let ignoreAnchorScroll = false;
const isViewportAtBottom = (): boolean =>
scrollElement.scrollHeight <= scrollElement.clientHeight
Expand Down Expand Up @@ -870,5 +878,3 @@ function dispose(dotNetHelper: DotNet.DotNetObject): void {
// even if init() returned early and no observers were created.
dotNetHelper.dispose();
}


3 changes: 2 additions & 1 deletion src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
if (firstRender)
{
_jsInterop = new VirtualizeJsInterop(this, JSRuntime);
await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter, (int)AnchorMode);
var suppressSpacerCallbacks = InitialItemIndex > 0;
await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter, (int)AnchorMode, suppressSpacerCallbacks);
_lastRenderedAnchorMode = AnchorMode;
}

Expand Down
14 changes: 12 additions & 2 deletions src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,20 @@ public VirtualizeJsInterop(IVirtualizeJsCallbacks owner, IJSRuntime jsRuntime)
_jsRuntime = jsRuntime;
}

public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter, int anchorMode)
public async ValueTask InitializeAsync(
ElementReference spacerBefore,
ElementReference spacerAfter,
int anchorMode,
bool suppressInitialSpacerCallbacks)
{
_selfReference = DotNetObjectReference.Create(this);
await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter, anchorMode);
await _jsRuntime.InvokeVoidAsync(
$"{JsFunctionsPrefix}.init",
_selfReference,
spacerBefore,
spacerAfter,
anchorMode,
suppressInitialSpacerCallbacks);
}

[JSInvokable]
Expand Down
36 changes: 36 additions & 0 deletions src/Components/Web/test/Virtualization/VirtualizeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Components.Web.Virtualization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;
using Moq;

namespace Microsoft.AspNetCore.Components.Virtualization;
Expand Down Expand Up @@ -1127,6 +1128,41 @@ public async Task InitialIndex_ParameterRoundTrip()
Assert.Equal(42, renderedVirtualize.InitialItemIndex);
}

[Theory]
[InlineData(0, false)]
[InlineData(42, true)]
public async Task InitialIndex_ControlsInitialSpacerCallbackSuppression(int initialItemIndex, bool expectedSuppression)
{
var jsRuntime = new Mock<IJSRuntime>(MockBehavior.Loose);
var rootComponent = new VirtualizeTestHostcomponent
{
InnerContent = builder =>
{
builder.OpenComponent<Virtualize<int>>(0);
builder.AddComponentParameter(1, "ItemSize", 50f);
builder.AddComponentParameter(2, "Items", (ICollection<int>)Enumerable.Range(1, 100).ToList());
builder.AddComponentParameter(3, "InitialItemIndex", initialItemIndex);
builder.AddComponentParameter(4, "ChildContent", (RenderFragment<int>)(item => b => b.AddContent(0, item)));
builder.CloseComponent();
}
};

var serviceProvider = new ServiceCollection()
.AddTransient((sp) => jsRuntime.Object)
.BuildServiceProvider();

var testRenderer = new TestRenderer(serviceProvider);
var componentId = testRenderer.AssignRootComponentId(rootComponent);
await testRenderer.RenderRootComponentAsync(componentId);

jsRuntime.Verify(runtime => runtime.InvokeAsync<IJSVoidResult>(
"Blazor._internal.Virtualize.init",
It.Is<object[]>(arguments =>
arguments.Length == 5
&& Equals(arguments[4], expectedSuppression))),
Times.Once);
}

[Fact]
public async Task InitialIndex_DefaultZeroMeansNoInitialScroll()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ public void Virtualize_Works_WhenMultipleRenderModesAreActive()
Browser.True(() => GetRenderedItems(Browser.FindElement(By.Id("virtualize-webassembly"))).Contains("Item 50"));
}

[Fact]
public void InitialItemIndex_IsAppliedOnPrerenderedInteractiveServerLoad()
Comment thread
kotlarmilos marked this conversation as resolved.
Resolved
{
Navigate($"{ServerPathBase}/interactivity/virtualization?test-initial-item-index=true");

Browser.True(() =>
{
var container = Browser.FindElement(By.Id("scroll-container"));
var target = container.FindElement(By.CssSelector(".item[data-index='500']"));
var scrollTop = double.Parse(container.GetDomProperty("scrollTop"), CultureInfo.InvariantCulture);

return scrollTop > 0 && Math.Abs(target.Location.Y - container.Location.Y) <= 1;
},
Comment thread
ilonatommy marked this conversation as resolved.
Resolved
"Expected InitialItemIndex=500 to remain aligned after the initial spacer callback.");
}

[Theory]
[InlineData(false)]
[InlineData(true)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
{
@if (useItemsProvider)
{
<Virtualize @ref="virtualizeRef" ItemsProvider="@GetItemsAsync" ItemSize="50" OverscanCount="@overscanCount" AnchorMode="@anchorMode" @attributes="ComparerSplat" InitialItemIndex="@initialItemIndex" Context="item">
<Virtualize @ref="virtualizeRef" ItemsProvider="@GetItemsAsync" ItemSize="50" OverscanCount="@overscanCount" AnchorMode="@anchorMode" @attributes="ComparerSplat" InitialItemIndex="@InitialItemIndex" Context="item">
<ItemContent>
<div class="item"
data-index="@item.Index"
Expand All @@ -38,7 +38,7 @@
}
else
{
<Virtualize @ref="virtualizeRef" Items="@items" ItemSize="50" OverscanCount="@overscanCount" AnchorMode="@anchorMode" InitialItemIndex="@initialItemIndex" Context="item">
<Virtualize @ref="virtualizeRef" Items="@items" ItemSize="50" OverscanCount="@overscanCount" AnchorMode="@anchorMode" InitialItemIndex="@InitialItemIndex" Context="item">
<div class="item"
data-index="@item.Index"
data-anchor-mode="@((int)anchorMode)"
Expand All @@ -56,11 +56,11 @@

<div style="margin-top: 6px;">
<label>Initial Index:
<input id="manual-initial-index" type="number" @bind="manualInitialIndex" style="width: 80px;" />
<input id="manual-initial-index" type="number" @bind="InitialItemIndex" style="width: 80px;" />
</label>
<button id="reload-with-initial-index" @onclick="ReloadWithInitialIndex">Load list with Initial Index</button>
<button id="unload-list" @onclick="UnloadList">Unload list</button>
<span id="manual-initial-index-bound" data-value="@manualInitialIndex" style="margin-left: 8px; color:#888;">bound:@manualInitialIndex</span>
<span id="manual-initial-index-bound" data-value="@InitialItemIndex" style="margin-left: 8px; color:#888;">bound:@InitialItemIndex</span>
</div>

<div style="margin-top: 6px;">
Expand Down Expand Up @@ -122,15 +122,16 @@
private int providerCallIndex;
private List<string> providerEvents = new();
private Virtualize<DynamicItem> virtualizeRef;
private int initialItemIndex;
private int manualInitialIndex;
private bool listLoaded = true;
private int scrollTargetIndex = 200;
private string scrollStatus = "Idle";
private CancellationTokenSource _userScrollCts;
private static readonly IEqualityComparer<DynamicItem> _itemComparer =
EqualityComparer<DynamicItem>.Create((a, b) => a.Index == b.Index, item => item.Index);

[Parameter]
public int InitialItemIndex { get; set; }

private static readonly IReadOnlyDictionary<string, object> _emptyComparerSplat =
new Dictionary<string, object>();

Expand Down Expand Up @@ -440,7 +441,6 @@
listLoaded = false;
StateHasChanged();
await Task.Yield();
initialItemIndex = manualInitialIndex;
listLoaded = true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
@page "/interactivity/virtualization"
@page "/interactivity/virtualization"
@using BasicTestApp
@using TestContentPackage;

<h3>Server</h3>
@if (TestInitialItemIndex)
{
<VirtualizationAnchorMode @rendermode="@RenderMode.InteractiveServer" InitialItemIndex="500" />
}
else
{
<h3>Server</h3>

<VirtualizedItemList @rendermode="@RenderMode.InteractiveServer" Id="virtualize-server" Items="_items" />
<VirtualizedItemList @rendermode="@RenderMode.InteractiveServer" Id="virtualize-server" Items="_items" />

<h3>WebAssembly</h3>
<h3>WebAssembly</h3>

<VirtualizedItemList @rendermode="@RenderMode.InteractiveWebAssembly" Id="virtualize-webassembly" Items="_items" />
<VirtualizedItemList @rendermode="@RenderMode.InteractiveWebAssembly" Id="virtualize-webassembly" Items="_items" />
}

@code {
private readonly string[] _items = Enumerable.Range(1, 50)
.Select(i => $"Item {i}")
.ToArray();

[Parameter]
[SupplyParameterFromQuery(Name = "test-initial-item-index")]
public bool TestInitialItemIndex { get; set; }
}
Loading