Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
e47d67c
feat(Virtualize): Support variable-height items
ilonatommy Feb 9, 2026
c939f0e
test(Virtualize): Add unit tests for variable-height support
ilonatommy Feb 9, 2026
c47f845
test(Virtualize): Add comprehensive E2E tests for variable-height items
ilonatommy Feb 9, 2026
f01b85c
Update src/Components/test/testassets/BasicTestApp/VirtualizationVari…
ilonatommy Feb 9, 2026
83f5724
Update src/Components/test/E2ETest/Tests/VirtualizationTest.cs
ilonatommy Feb 9, 2026
f475bc3
Update src/Components/test/E2ETest/Tests/VirtualizationTest.cs
ilonatommy Feb 10, 2026
1701a0e
Feedback: clean tests, detect backwards movements.
ilonatommy Feb 10, 2026
19e573d
Feedback: 0 division and wrapper for virtualzation items so that heig…
ilonatommy Feb 16, 2026
545e5ab
Fix https://github.com/dotnet/aspnetcore/issues/64029 and https://git…
ilonatommy Feb 17, 2026
db76114
Increase the lenght of list in jump tests to 1k + increase them to 20…
ilonatommy Feb 18, 2026
2c08839
Additional optimizations.
ilonatommy Feb 18, 2026
e83bbb0
Simplify the comment.
ilonatommy Feb 18, 2026
2f9c441
Stricker tests (do not retry jumping on failure) + scroll adjustment …
ilonatommy Feb 18, 2026
587dc21
Clean up redundant comments.
ilonatommy Feb 18, 2026
aad16d3
Only WASM scrolling tests can be deterministic.
ilonatommy Feb 19, 2026
cca6a87
Try fixing the jump and scrolling.
ilonatommy Feb 19, 2026
257e016
Stricter QuickGrid jump test.
ilonatommy Feb 20, 2026
d75e37a
Tests wait longer.
ilonatommy Feb 20, 2026
7c2b442
Clean comments, refactor code.
ilonatommy Feb 20, 2026
d4fe9c8
Add logging to debug CI-only failures.
ilonatommy Feb 20, 2026
a30119b
On CI: Loaded items move spacer out of area that we check for interse…
ilonatommy Feb 20, 2026
a059c39
Fix CI: differenciate seeing spacer caused by scrolling and caused by…
ilonatommy Feb 24, 2026
0d52ca4
Another attempt.
ilonatommy Feb 25, 2026
618c4eb
Ugly fix after reproducing the CI problem with Linux.
ilonatommy Feb 26, 2026
aa7672b
Cleanup.
ilonatommy Feb 26, 2026
033f666
More cleanup.
ilonatommy Feb 26, 2026
6896fec
Rename to clarify.
ilonatommy Mar 4, 2026
f56eaac
Better test coverage.
ilonatommy Mar 4, 2026
24b8380
Try enabling the problematic test on server.
ilonatommy Mar 4, 2026
bd1087b
Merge branch 'main' into fix-25058-variable-height
ilonatommy Mar 9, 2026
34ccefd
Test jump to home with async load, symmetric to jump to end.
ilonatommy Mar 11, 2026
8d69bb4
Increase unit tests coverage.
ilonatommy Mar 11, 2026
65872fb
Merge branch 'fix-25058-variable-height' of https://github.com/ilonat…
ilonatommy Mar 11, 2026
d8fcfc3
More test coverage.
ilonatommy Mar 11, 2026
2238e57
Feedback: document breaking change.
ilonatommy Mar 12, 2026
3d6583f
Feedback: nested virtualization has better test coverage.
ilonatommy Mar 12, 2026
92ecf38
Merge remote-tracking branch 'origin/main' into fix-25058-variable-he…
ilonatommy Mar 12, 2026
96f43fd
Add test for measurements pollution with NaN and similar.
ilonatommy Mar 13, 2026
f1e8270
Feedback: update ambigious comment.
ilonatommy Mar 13, 2026
bea9654
Merge remote-tracking branch 'upstream/main' into fix-25058-variable-…
ilonatommy Mar 16, 2026
0738909
Feedback: use `ItemsProviderCallCount` without interlocked.
ilonatommy Mar 18, 2026
569c4be
Feedback: description for complex razor files.
ilonatommy Mar 18, 2026
3574fad
Feedback: attribute sequence indexing starts at 0.
ilonatommy Mar 18, 2026
5b12a88
Feedback: replace wrapper element with wrapper comment to avoid break…
ilonatommy Mar 18, 2026
58fc1d5
Fix JS tests.
ilonatommy Mar 18, 2026
5ec2177
Feedback: optimize C# -> JS communication costs.
ilonatommy Mar 18, 2026
c1accfd
Wrapper auto-provided `tr` but with comment approach we have to corre…
ilonatommy Mar 18, 2026
a1b9614
Revert https://github.com/dotnet/aspnetcore/pull/64964/commits/5b12a8…
ilonatommy Mar 18, 2026
6c68072
Missing change for the revert.
ilonatommy Mar 19, 2026
4aabc44
Fix `QuickGridTest.ItemsProviderCalledOnceWithVirtualize`: Avoid redu…
ilonatommy Mar 19, 2026
a0dd443
Fix failing Flashing tests and dual item provider call tests.
ilonatommy Mar 19, 2026
4e7a9b1
Remove breaking change: use comments instead of wraper element.
ilonatommy Mar 19, 2026
29b1148
Update Virtualize.test.ts for comment-based rendering
ilonatommy Mar 19, 2026
8dd290c
Merge branch 'main' into fix-25058-variable-height
ilonatommy Mar 20, 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
322 changes: 277 additions & 45 deletions src/Components/Web.JS/src/Virtualize.ts

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions src/Components/Web.JS/test/Virtualize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { expect, test, describe, beforeEach, afterEach } from '@jest/globals';
import { Virtualize } from '../src/Virtualize';

const { measureRenderedItems } = Virtualize;

function createDOM(heights: number[]): { before: HTMLDivElement; after: HTMLDivElement } {
const container = document.createElement('div');
document.body.appendChild(container);
const before = document.createElement('div');
const after = document.createElement('div');
container.appendChild(before);

// Build N+1 fence: comment, item, comment, item, ..., comment
for (const h of heights) {
const comment = document.createComment('virtualize:item');
container.appendChild(comment);
const item = document.createElement('div');
item.getBoundingClientRect = () => ({
height: h, width: 100, top: 0, left: 0, bottom: h, right: 100,
x: 0, y: 0, toJSON() { return this; },
});
container.appendChild(item);
}
if (heights.length > 0) {
container.appendChild(document.createComment('virtualize:item'));
}

container.appendChild(after);

// jsdom doesn't implement Range.getBoundingClientRect.
// Patch createRange to return a mock that sums item heights between start and end.
const origCreateRange = document.createRange.bind(document);
document.createRange = () => {
const range = origCreateRange();
let startNode: Node | null = null;
const origSetStartAfter = range.setStartAfter.bind(range);
const origSetEndBefore = range.setEndBefore.bind(range);
range.setStartAfter = (node: Node) => { startNode = node; origSetStartAfter(node); };
range.setEndBefore = (node: Node) => {
origSetEndBefore(node);
// Sum heights of element children between startNode and node
let totalHeight = 0;
if (startNode) {
for (let n = startNode.nextSibling; n && n !== node; n = n.nextSibling) {
if (n instanceof HTMLElement && n.getBoundingClientRect) {
totalHeight += n.getBoundingClientRect().height;
}
}
}
range.getBoundingClientRect = () => ({
height: totalHeight, width: 100, top: 0, left: 0, bottom: totalHeight, right: 100,
x: 0, y: 0, toJSON() { return this; },
} as DOMRect);
};
return range;
};

return { before, after };
}

describe('measureRenderedItems', () => {
test('returns aggregated sum and count for valid items', () => {
const { before, after } = createDOM([40, 60]);
const result = measureRenderedItems(before, after);
expect(result.heightSum).toBe(100);
expect(result.heightCount).toBe(2);
});

test('includes zero-height items in count', () => {
const { before, after } = createDOM([50, 0, 30]);
const result = measureRenderedItems(before, after);
// Total height is 80 (50+0+30), all 3 items counted
expect(result.heightSum).toBe(80);
expect(result.heightCount).toBe(3);
});

test('returns zero for empty item list', () => {
const { before, after } = createDOM([]);
const result = measureRenderedItems(before, after);
expect(result.heightSum).toBe(0);
expect(result.heightCount).toBe(0);
});

test('returns zero for single item (needs at least 2 delimiters)', () => {
// Single item has 2 delimiters which is the minimum
const { before, after } = createDOM([42]);
const result = measureRenderedItems(before, after);
expect(result.heightSum).toBe(42);
expect(result.heightCount).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace Microsoft.AspNetCore.Components.Web.Virtualization;

internal interface IVirtualizeJsCallbacks
{
void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize);
void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize);
void OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount);
void OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount);
}
140 changes: 114 additions & 26 deletions src/Components/Web/src/Virtualization/Virtualize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I

private float _itemSize;

private float _lastSetItemSize;

private IEnumerable<TItem>? _loadedItems;

private CancellationTokenSource? _refreshCts;

private bool _skipNextDistributionRefresh;

private Exception? _refreshException;

private ItemsProviderDelegate<TItem> _itemsProvider = default!;
Expand All @@ -59,11 +63,17 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I

private bool _loading;

internal float _totalMeasuredHeight;

internal int _measuredItemCount;

internal bool _pendingScrollToBottom;
Comment thread
oroztocil marked this conversation as resolved.

[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;

/// <summary>
/// Gets or sets the item template for the list.
/// Gets or sets the item template for the list. See <see cref="ItemContent"/>.
/// </summary>
[Parameter]
public RenderFragment<TItem>? ChildContent { get; set; }
Expand Down Expand Up @@ -112,7 +122,7 @@ public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, I
/// in the page.
/// </summary>
[Parameter]
public int OverscanCount { get; set; } = 3;
public int OverscanCount { get; set; } = 15;
Comment thread
ilonatommy marked this conversation as resolved.
Comment thread
ilonatommy marked this conversation as resolved.

/// <summary>
/// Gets or sets the tag name of the HTML element that will be used as the virtualization spacer.
Expand Down Expand Up @@ -148,6 +158,8 @@ public async Task RefreshDataAsync()
// We don't auto-render after this operation because in the typical use case, the
// host component calls this from one of its lifecycle methods, and will naturally
// re-render afterwards anyway. It's not desirable to re-render twice.
_totalMeasuredHeight = 0;
_measuredItemCount = 0;
await RefreshDataCoreAsync(renderOnSuccess: false);
}

Expand All @@ -165,6 +177,15 @@ protected override void OnParametersSet()
_itemSize = ItemSize;
}

// Without this reset, visibleItemCapacity is under/over-estimated after a size change,
// causing extra provider calls that may never complete (e.g., async providers).
if (_lastSetItemSize != ItemSize)
{
_lastSetItemSize = ItemSize;
_totalMeasuredHeight = 0;
_measuredItemCount = 0;
}

if (ItemsProvider != null)
{
if (Items != null)
Expand Down Expand Up @@ -208,6 +229,18 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
_jsInterop = new VirtualizeJsInterop(this, JSRuntime);
await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter);
}

if (_pendingScrollToBottom && _jsInterop is not null)
{
_pendingScrollToBottom = false;
await _jsInterop.ScrollToBottomAsync();
}

// After render the set of items could change. Tell JS to refresh ResizeObserver.
if (!firstRender && _jsInterop is not null)
{
await _jsInterop.RefreshObserversAsync();
}
}

/// <inheritdoc />
Expand Down Expand Up @@ -257,13 +290,19 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)

builder.OpenRegion(5);

// Render the loaded items.
// Render items with comment delimiters for JS height measurement (N+1 fence pattern).
foreach (var item in itemsToShow)
{
builder.AddMarkupContent(0, "<!--virtualize:item-->");
_itemTemplate(item)(builder);
_lastRenderedItemCount++;
}

if (_lastRenderedItemCount > 0)
{
builder.AddMarkupContent(1, "<!--virtualize:item-->");
}

renderIndex += _lastRenderedItemCount;

builder.CloseRegion();
Expand Down Expand Up @@ -292,48 +331,71 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
}

private string GetSpacerStyle(int itemsInSpacer, int numItemsGapAbove)
=> numItemsGapAbove == 0
? GetSpacerStyle(itemsInSpacer)
: $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * _itemSize).ToString(CultureInfo.InvariantCulture)}px);";
{
var avgHeight = GetItemHeight();
return numItemsGapAbove == 0
? GetSpacerStyle(itemsInSpacer)
: $"height: {(itemsInSpacer * avgHeight).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * avgHeight).ToString(CultureInfo.InvariantCulture)}px);";
}

private string GetSpacerStyle(int itemsInSpacer)
=> $"height: {(itemsInSpacer * _itemSize).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;";
=> $"height: {(itemsInSpacer * GetItemHeight()).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;";

private float GetItemHeight()
=> _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize;

void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
private bool ProcessMeasurements(float measuredItemHeightSum, int measuredItemCount)
{
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity);
if (measuredItemCount <= 0)
{
return false;
}

_totalMeasuredHeight += measuredItemHeightSum;
_measuredItemCount += measuredItemCount;
return true;
}

void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
{
ProcessMeasurements(measuredItemHeightSum, measuredItemCount);

// Since we know the before spacer is now visible, we absolutely have to slide the window up
// by at least one element. If we're not doing that, the previous item size info we had must
// have been wrong, so just move along by one in that case to trigger an update and apply the
// new size info.
if (itemsBefore == _itemsBefore && itemsBefore > 0)
CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsBefore, out var visibleItemCapacity, out var unusedItemCapacity);

// Slide window up by at least one if spacer is visible but position unchanged.
if (_lastRenderedItemCount > 0 && itemsBefore == _itemsBefore && itemsBefore > 0)
{
itemsBefore--;
}

UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
}

void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize)
void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
{
CalcualteItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity);
var hadNewMeasurements = ProcessMeasurements(measuredItemHeightSum, measuredItemCount);

CalculateItemDistribution(spacerSize, spacerSeparation, containerSize, out var itemsAfter, out var visibleItemCapacity, out var unusedItemCapacity);

var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity);

// Since we know the after spacer is now visible, we absolutely have to slide the window down
// by at least one element. If we're not doing that, the previous item size info we had must
// have been wrong, so just move along by one in that case to trigger an update and apply the
// new size info.
if (itemsBefore == _itemsBefore && itemsBefore < _itemCount - visibleItemCapacity)
// Slide window down by at least one if spacer is visible but position unchanged.
if (_lastRenderedItemCount > 0 && itemsBefore == _itemsBefore && itemsBefore < _itemCount - visibleItemCapacity)
{
itemsBefore++;
}

// When we're at the very bottom and new measurements arrived,
// scroll to bottom so the viewport stays pinned while items converge.
if (itemsAfter == 0 && hadNewMeasurements)
{
_pendingScrollToBottom = true;
}

UpdateItemDistribution(itemsBefore, visibleItemCapacity, unusedItemCapacity);
}

private void CalcualteItemDistribution(
private void CalculateItemDistribution(
float spacerSize,
float spacerSeparation,
float containerSize,
Expand Down Expand Up @@ -366,8 +428,15 @@ private void CalcualteItemDistribution(
// the user has set a very low MaxItemCount and we end up in an infinite loading loop.
maxItemCount += OverscanCount * 2;

itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / _itemSize) - OverscanCount);
visibleItemCapacity = (int)Math.Ceiling(containerSize / _itemSize) + 2 * OverscanCount;
// Use average measured height for calculations, falling back to _itemSize to avoid division by zero
var effectiveItemSize = GetItemHeight();
if (effectiveItemSize <= 0 || float.IsNaN(effectiveItemSize) || float.IsInfinity(effectiveItemSize))
{
effectiveItemSize = _itemSize;
}

Comment thread
ilonatommy marked this conversation as resolved.
itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / effectiveItemSize) - OverscanCount);
visibleItemCapacity = (int)Math.Ceiling(containerSize / effectiveItemSize) + 2 * OverscanCount;
unusedItemCapacity = Math.Max(0, visibleItemCapacity - maxItemCount);
visibleItemCapacity -= unusedItemCapacity;
}
Expand All @@ -387,12 +456,30 @@ private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity, in
_itemsBefore = itemsBefore;
_visibleItemCapacity = visibleItemCapacity;
_unusedItemCapacity = unusedItemCapacity;
var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true);

if (!refreshTask.IsCompleted)
// After a successful data load, the ResizeObserver→IntersectionObserver cycle
// re-triggers with refined measurements. This one-shot flag skips the single
// redundant provider call that follows. At end-of-list, don't skip: refined
// capacity may reveal that more items are needed to fill the viewport.
var skipRefresh = _skipNextDistributionRefresh
&& _loadedItems != null
&& _loadedItemsStartIndex == _itemsBefore
&& _itemsBefore + visibleItemCapacity < _itemCount;
_skipNextDistributionRefresh = false;

if (skipRefresh)
{
StateHasChanged();
}
else
{
var refreshTask = RefreshDataCoreAsync(renderOnSuccess: true);

if (!refreshTask.IsCompleted)
{
StateHasChanged();
}
}
}
}

Expand Down Expand Up @@ -429,6 +516,7 @@ private async ValueTask RefreshDataCoreAsync(bool renderOnSuccess)
_loadedItems = result.Items;
_loadedItemsStartIndex = request.StartIndex;
_loading = false;
_skipNextDistributionRefresh = request.Count > 0;

if (renderOnSuccess)
{
Expand Down
18 changes: 14 additions & 4 deletions src/Components/Web/src/Virtualization/VirtualizeJsInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,25 @@ public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementRef
}

[JSInvokable]
public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize)
public void OnSpacerBeforeVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
{
_owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize);
_owner.OnBeforeSpacerVisible(spacerSize, spacerSeparation, containerSize, measuredItemHeightSum, measuredItemCount);
}

[JSInvokable]
public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize)
public void OnSpacerAfterVisible(float spacerSize, float spacerSeparation, float containerSize, float measuredItemHeightSum, int measuredItemCount)
{
_owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize);
_owner.OnAfterSpacerVisible(spacerSize, spacerSeparation, containerSize, measuredItemHeightSum, measuredItemCount);
}

public ValueTask ScrollToBottomAsync()
{
return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.scrollToBottom", _selfReference);
}

public ValueTask RefreshObserversAsync()
{
return _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.refreshObservers", _selfReference);
}

public async ValueTask DisposeAsync()
Expand Down
Loading
Loading