Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
<div @ref="_rootElement" style="@_containerStyle">
@foreach (var row in _rowsToRender)
{
@* data-key is how the JS side recognises a row it may move before this render catches up -
see syncRenderedRows in adaptive-virtualize-interop.js. *@
<div @key="row.Key"
data-index="@row.Index"
data-key="@row.Key"
style="@row.Style">
@ItemContent(row.Item)
</div>
Expand Down
26 changes: 14 additions & 12 deletions src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,18 +185,20 @@
var key = ItemKeyExtractor(item);

// If the row at this index has changed since the virtualizer emitted this virtualItem
// (e.g. a group expanded and inserted children), the virtualItem's start/size is stale
// for the new content. Rendering with the stale offset visibly misplaces the row until
// the next JS round-trip arrives with fresh positions - on slow JS interop (Android
// WebView) that gap is hundreds of ms, long enough to see rows land where other rows
// used to be. Skip the mismatched row so unchanged rows stay visible and changed rows
// go blank until JS catches up.
if (key != virtualItem.Key)
{
continue;
}

_preparedRows.Add(new PreparedRow(key, virtualItem.Index, GetItemStyle(virtualItem), item));
// (e.g. a group expanded and inserted children, or a filter replaced the set), the
// virtualItem's start/size is stale for the new content. Painting at the stale offset
// visibly misplaces the row until the next JS round-trip arrives with fresh positions -
// on slow JS interop (Android WebView) that gap is hundreds of ms, long enough to see
// rows land where other rows used to be. So the row goes blank until JS catches up, but
// it is still rendered under its key: skipping it instead disposed every row component
// on each item-set change and built it again a round trip later, even for rows that
// are in both sets, and the new rows could not be measured until then either.
// JS catches up as soon as setItems reaches it, not a round trip later: it places and
// shows every row whose data-key matches its new set (syncRenderedRows).
var style = key == virtualItem.Key
? GetItemStyle(virtualItem)
: $"{GetItemStyle(virtualItem)}visibility:hidden;";
_preparedRows.Add(new PreparedRow(key, virtualItem.Index, style, item));
}

return _preparedRows;
Expand Down Expand Up @@ -233,7 +235,7 @@
return value.ToString("0.####", CultureInfo.InvariantCulture);
}

protected override async Task OnAfterRenderAsync(bool firstRender)

Check warning on line 238 in src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'AdaptiveVirtualize<TItem>.OnAfterRenderAsync(bool)'

Check warning on line 238 in src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'AdaptiveVirtualize<TItem>.OnAfterRenderAsync(bool)'
{
if (firstRender)
{
Expand Down Expand Up @@ -570,14 +572,14 @@
}

[JSInvokable]
public void OnVirtualItemsChanged(VirtualItem[] virtualItems, double totalSize)

Check warning on line 575 in src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'AdaptiveVirtualize<TItem>.OnVirtualItemsChanged(VirtualItem[], double)'

Check warning on line 575 in src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'AdaptiveVirtualize<TItem>.OnVirtualItemsChanged(VirtualItem[], double)'
{
_virtualItems = virtualItems;
_totalSize = totalSize;
StateHasChanged();
}

public async ValueTask DisposeAsync()

Check warning on line 582 in src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'AdaptiveVirtualize<TItem>.DisposeAsync()'

Check warning on line 582 in src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'AdaptiveVirtualize<TItem>.DisposeAsync()'
{
_isDisposed = true;
await ReleaseJsAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@
<PackageIcon>icon.png</PackageIcon>
<PackageTags>blazor;virtualization;virtual-scroll;tanstack;variable-height;list;performance</PackageTags>
<!-- First three parts track the bundled @tanstack/virtual-core release; the fourth is this wrapper's own revision. -->
<Version>3.19.8.2</Version>
<PackageReleaseNotes>3.19.8.2: an Items change sends only what the JS side does not hold yet, and snapshots report current sizes.
<Version>3.19.8.3</Version>
<PackageReleaseNotes>3.19.8.3: rows keep their components across an Items change, and JS places them without waiting for .NET.

A row whose placement went stale on an Items change used to be skipped, so every row component was disposed and built again one round trip later, even rows in both sets; it is now rendered under its own key and hidden until a matching placement arrives. Rows carry a data-key attribute, and the JS side moves every row whose key matches its own item set as soon as it knows the new layout - inside the ResizeObserver callback, before the frame is painted, and when setItems arrives, which also shows the rows .NET hid - instead of one interop round trip later through a .NET render. On a slow WebView bridge that removes the overlap below a row that grew and most of the blank after an insert. A size the ResizeObserver measured now survives the setItems that follows InvalidateItemSize, and a row rendered from an item set JS has not received yet no longer has its size recorded under another item's key.

3.19.8.2: an Items change sends only what the JS side does not hold yet, and snapshots report current sizes.

A change of Items used to send every key and size estimate across the interop bridge again; in a Blazor Hybrid app that is JSON copied through the WebView bridge per keystroke of a search. Both sides now keep a table of the pairs already sent, so a change travels as slot ids (runs packed into two numbers) plus only the new pairs, and an unchanged set sends nothing but a null order: for a 4,010-row search the setItems payload went from 293,225 to 21,170 characters. Per-key measurements survive a change as before. TakeSnapshot, GetOffsetForIndex, ScrollToIndex and GetSnapshot's StartIndex/EndIndex no longer read virtual-core state that lags after a ResizeObserver delivery or an InvalidateItemSize, so they report the sizes and range the virtualizer actually uses, and ScrollToIndex lands its first write on the target.

Expand Down
119 changes: 119 additions & 0 deletions src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ function compact(source) {

const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve()));

/**
* The key of the item a row renders. Contract with AdaptiveVirtualize.razor, which writes it on every
* row; syncRenderedRows and the resize measurement read it to tell whether a row belongs to the item
* set this instance holds.
*/
const KEY_ATTRIBUTE = 'data-key';

/** Mirrors ToCss on the .NET side, so a row placed from either side carries the same value. */
function toCssNumber(value) {
return Number(value.toFixed(4));
}

class AdaptiveVirtualizeInstance {
// Keys and sizes arrive as two parallel arrays rather than one array of objects: this covers the
// WHOLE item set, not just the rendered window, and it crosses the interop boundary as JSON. For
Expand Down Expand Up @@ -111,6 +123,24 @@ class AdaptiveVirtualizeInstance {
: null;
this.containerObserver?.observe(rootElement);

// A .NET render can still be carrying placements older than the ones syncRenderedRows has
// already applied, and writing them would move the rows back until the next round trip. The
// observer runs before the browser paints, so the stale value is never seen. Its own writes
// come back here too and change nothing the second time.
this.rowObserver = typeof MutationObserver === 'function'
? new MutationObserver(records => {
if (records.some(record => record.target === rootElement || record.target.parentElement === rootElement)) {
this.syncRenderedRows();
}
})
: null;
this.rowObserver?.observe(rootElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', this.options.indexAttribute ?? 'data-index', KEY_ATTRIBUTE],
});

this.notifyChange();
}

Expand Down Expand Up @@ -143,6 +173,25 @@ class AdaptiveVirtualizeInstance {
measureElement: (element, entry, instance) => {
if (entry) {
this.markInsideResizeObserver();
const renderedKey = element.getAttribute(KEY_ATTRIBUTE);
const index = Number(element.getAttribute(this.options.indexAttribute ?? 'data-index'));
const heldItem = instance.measurementsCache[index];
if (renderedKey !== null && heldItem && renderedKey !== this.itemKeys[index]) {
// The row was rendered from an item set this instance has not received yet, so its
// index still belongs to another item here, and virtual-core would record the size
// under that item's key - the likely way an exercise row once ended up with a section
// header's 38px and overlapped the row below for good. Report the size already held
// instead, and leave the row to the measureRenderedRows that follows the coming setItems.
this.measuredKeys.delete(renderedKey);
return instance.itemSizeCache.get(heldItem.key) ?? heldItem.size;
}
// A ResizeObserver measurement is as fresh as one from measureRenderedRows, and setItems
// keeps only the sizes of keys recorded as measured. Without this, a row that grew right
// after InvalidateItemSize - a group expanding - lost its new size to the next setItems
// and fell back to the estimate until measureRenderedRows ran, half a round trip later.
if (renderedKey !== null && renderedKey === this.itemKeys[index]) {
this.measuredKeys.add(renderedKey);
}
}
return measureElement(element, entry, instance);
},
Expand Down Expand Up @@ -310,6 +359,8 @@ class AdaptiveVirtualizeInstance {
return;
}

this.syncRenderedRows();

if (this.insideResizeObserver) {
if (this.pendingNotification === null) {
this.pendingNotification = setTimeout(() => {
Expand All @@ -336,6 +387,73 @@ class AdaptiveVirtualizeInstance {
this.dotNetRef.invokeMethodAsync('OnVirtualItemsChanged', virtualItems.map(toPayload), totalSize);
}

/**
* Moves the rendered rows to where the virtualizer now places them, without waiting for .NET.
*
* Placements otherwise reach the DOM only through a .NET render, one interop round trip after the
* virtualizer computed them - ~250 ms on a mid-range Android phone (Samsung A53). For that long a
* row that grew paints over the rows below it, and after an item-set change every row whose index
* moved stays hidden. The virtualizer learns a new size inside the ResizeObserver callback, before
* the frame is painted, so moving the rows here shows them in place in the very frame the row grew.
*
* A row is touched only when the key it renders is the key the virtualizer holds for its index.
* A row .NET drew from an item set this instance has not received yet is left as .NET drew it, and
* so is one .NET hid - until setItems brings the set it belongs to, which is also when it is shown.
* Only the transform and the cross-axis offset change, neither of which resizes the row, so this
* cannot set off another resize observation. The container's size is left to .NET: resizing it
* from inside the callback would.
*/
syncRenderedRows() {
const { horizontal, isRtl } = this.virtualizer.options;
const lanes = Math.max(this.virtualizer.options.lanes ?? 1, 1);
const measurements = this.virtualizer.getMeasurements();

for (const row of this.rootElement.children) {
const index = this.indexOfCurrentRow(row);
const measurement = measurements[index];
if (!measurement) {
continue;
}

const start = toCssNumber(measurement.start);
const transform = horizontal
? `translateX(${isRtl ? '-' : ''}${start}px)`
: `translateY(${start}px)`;
if (row.style.transform !== transform) {
row.style.transform = transform;
}

if (lanes > 1) {
const crossStart = `${toCssNumber((measurement.lane ?? 0) * (100 / lanes))}%`;
const crossProperty = horizontal ? 'top' : 'left';
if (row.style[crossProperty] !== crossStart) {
row.style[crossProperty] = crossStart;
}
}

if (row.style.visibility === 'hidden') {
row.style.visibility = '';
}
}
}

/**
* The index of a rendered row, or -1 when the key it renders is not the key this instance holds for
* that index: .NET renders a new item set one interop call before setItems brings it here.
*/
indexOfCurrentRow(row) {
const indexText = row.getAttribute(this.options.indexAttribute ?? 'data-index');
const key = row.getAttribute(KEY_ATTRIBUTE);
if (indexText === null || key === null) {
return -1;
}

const index = Number(indexText);
return this.itemKeys[index] === key
? index
: -1;
}

areVirtualItemsEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
Expand Down Expand Up @@ -650,6 +768,7 @@ class AdaptiveVirtualizeInstance {

dispose() {
this.containerObserver?.disconnect();
this.rowObserver?.disconnect();
clearTimeout(this.pendingNotification);
for (const [type, listener] of Object.entries(this.inputListeners)) {
window.removeEventListener(type, listener, { capture: true });
Expand Down
80 changes: 80 additions & 0 deletions tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,86 @@ public async Task Dispose_WithAResizeNotificationStillQueued_NeverNotifiesDotNet
_fixture.AssertNoJsErrors();
}

/// <summary>
/// A group expanding calls InvalidateItemSize, renders the grown row, and only then sends the new item set - in
/// the app a separate interop message, which the ResizeObserver measuring the grown row gets ahead of. setItems
/// keeps only sizes it knows to be measured, and dropping that one put every row below back on the estimate
/// until the next measureRenderedRows, half a round trip later: on a phone, long enough to see them overlap.
/// </summary>
[Fact]
public async Task SetItems_AfterAResizeObserverMeasuredAnInvalidatedRow_KeepsItsNewSize()
{
// Arrange
await _fixture.NavigateToStoryAsync(_story);

// Act
var secondRowTransform = await _fixture.Page.EvaluateAsync<string>(
"async () => {" + _mountScript + _measuredRowsScript + @"
const list = await mountMeasured(100, {});
list.rows.forEach((row, index) => row.setAttribute('data-key', `row-${index}`));
list.instance.invalidateItemSize('row-0');
list.rows[0].style.height = '200px';
await nextFrame();
await nextFrame();

const keys = Array.from({ length: 100 }, (_, index) => `row-${index}`);
list.instance.setItems(keys.map((_, index) => index), keys, keys.map(() => 50), true);
const transform = list.rows[1].style.transform;

list.viewport.remove();
return transform;
}");

// Assert
// The estimate would have put row 1 at 50px.
Assert.Equal("translateY(200px)", secondRowTransform);
_fixture.AssertNoJsErrors();
}

/// <summary>
/// .NET renders a new item set one interop message before setItems brings it to JS, and Blazor moves keyed rows
/// to their new index meanwhile. A row that resizes in that window carries an index this instance still holds
/// for another item, and recording its size there left an exercise row with a section header's height.
/// </summary>
[Fact]
public async Task ResizeObserver_RowFromANewerItemSet_IsMeasuredOnlyUnderItsOwnKey()
{
// Arrange
await _fixture.NavigateToStoryAsync(_story);

// Act
var sizes = await _fixture.Page.EvaluateAsync<string[]>(
"async () => {" + _mountScript + _measuredRowsScript + @"
const list = await mountMeasured(100, {});
list.rows.forEach((row, index) => row.setAttribute('data-key', `row-${index}`));
const sizesOfFirstRows = () => list.instance.takeSnapshot().slice(0, 3).map(x => `${x.key}=${x.size}`);

// The set [row-0, row-2, row-1, ...] as .NET renders it: row-2's element moves to index 1,
// which this instance still holds for row-1, and grows there.
list.rows[1].setAttribute('data-index', '2');
list.rows[2].setAttribute('data-index', '1');
list.root.insertBefore(list.rows[2], list.rows[1]);
list.rows[2].style.height = '300px';
await nextFrame();
await nextFrame();
const beforeSetItems = sizesOfFirstRows();

const keys = Array.from({ length: 100 }, (_, index) => `row-${index}`);
[keys[1], keys[2]] = [keys[2], keys[1]];
list.instance.setItems(keys.map((_, index) => index), keys, keys.map(() => 50), true);
list.instance.measureRenderedRows();
const afterSetItems = sizesOfFirstRows();

list.viewport.remove();
return [...beforeSetItems, ...afterSetItems];
}");

// Assert
Assert.Equal(["row-0=80", "row-1=30", "row-2=60"], sizes[..3]);
Assert.Equal(["row-0=80", "row-2=300", "row-1=30"], sizes[3..]);
_fixture.AssertNoJsErrors();
}

[Fact]
public async Task DirectionalOverscan_WithLanes_BuffersWholeRows()
{
Expand Down
Loading
Loading