diff --git a/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor b/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor
index bd76e2d..f68ab6b 100644
--- a/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor
+++ b/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor
@@ -8,8 +8,11 @@
@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. *@
@ItemContent(row.Item)
diff --git a/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs b/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs
index 4d6fe9b..59c0eac 100644
--- a/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs
+++ b/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs
@@ -185,18 +185,20 @@ private List
_rowsToRender
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;
diff --git a/src/Blazor.TanStack.VirtualCore/Blazor.TanStack.VirtualCore.csproj b/src/Blazor.TanStack.VirtualCore/Blazor.TanStack.VirtualCore.csproj
index af0a529..7a8e9a6 100644
--- a/src/Blazor.TanStack.VirtualCore/Blazor.TanStack.VirtualCore.csproj
+++ b/src/Blazor.TanStack.VirtualCore/Blazor.TanStack.VirtualCore.csproj
@@ -26,8 +26,12 @@
icon.png
blazor;virtualization;virtual-scroll;tanstack;variable-height;list;performance
- 3.19.8.2
- 3.19.8.2: an Items change sends only what the JS side does not hold yet, and snapshots report current sizes.
+ 3.19.8.3
+ 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.
diff --git a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js
index 6237595..48ba325 100644
--- a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js
+++ b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js
@@ -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
@@ -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();
}
@@ -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);
},
@@ -310,6 +359,8 @@ class AdaptiveVirtualizeInstance {
return;
}
+ this.syncRenderedRows();
+
if (this.insideResizeObserver) {
if (this.pendingNotification === null) {
this.pendingNotification = setTimeout(() => {
@@ -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;
@@ -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 });
diff --git a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs
index e83df05..a58e09e 100644
--- a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs
+++ b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs
@@ -121,6 +121,86 @@ public async Task Dispose_WithAResizeNotificationStillQueued_NeverNotifiesDotNet
_fixture.AssertNoJsErrors();
}
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task SetItems_AfterAResizeObserverMeasuredAnInvalidatedRow_KeepsItsNewSize()
+ {
+ // Arrange
+ await _fixture.NavigateToStoryAsync(_story);
+
+ // Act
+ var secondRowTransform = await _fixture.Page.EvaluateAsync(
+ "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();
+ }
+
+ ///
+ /// .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.
+ ///
+ [Fact]
+ public async Task ResizeObserver_RowFromANewerItemSet_IsMeasuredOnlyUnderItsOwnKey()
+ {
+ // Arrange
+ await _fixture.NavigateToStoryAsync(_story);
+
+ // Act
+ var sizes = await _fixture.Page.EvaluateAsync(
+ "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()
{
diff --git a/tests/Blazor.TanStack.VirtualCore.E2E/SlowInteropTests.cs b/tests/Blazor.TanStack.VirtualCore.E2E/SlowInteropTests.cs
new file mode 100644
index 0000000..032701b
--- /dev/null
+++ b/tests/Blazor.TanStack.VirtualCore.E2E/SlowInteropTests.cs
@@ -0,0 +1,193 @@
+using System.Text.Json.Serialization;
+using Xunit;
+
+namespace Blazor.TanStack.VirtualCore.E2E;
+
+///
+/// Placements reach the DOM through a .NET render, one interop round trip after the virtualizer computed them. On
+/// a phone's WebView that round trip takes hundreds of milliseconds (~250 ms measured on a Samsung A53), where the
+/// demo's in-browser WebAssembly answers within a frame. These tests hold every placement notification back to
+/// reproduce the phone, and check what is painted in the meantime.
+///
+[Collection(DemoCollectionDefinition.Name)]
+public sealed class SlowInteropTests
+{
+ /// Longer than every sample window below, so no placement from .NET lands inside one.
+ private const int PlacementDelayMilliseconds = 600;
+
+ private const int SampleDurationMilliseconds = 400;
+
+ /// Sub-pixel rounding between a row's bottom edge and the next row's top.
+ private const double OverlapTolerancePx = 1.5;
+
+ private readonly DemoFixture _fixture;
+
+ public SlowInteropTests(DemoFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// A row whose content grows without .NET knowing - an image arriving, a child rendering late - used to paint
+ /// over the rows below it until .NET re-rendered their placements.
+ ///
+ [Fact]
+ public async Task RowGrowing_UnderSlowInterop_RowsBelowMoveInTheSameFrame()
+ {
+ // Arrange
+ await _fixture.NavigateToStoryAsync("components-adaptivevirtualize--variable-heights");
+ await HoldBackPlacementsAsync();
+ var heightBefore = await _fixture.Page.EvaluateAsync(
+ "() => document.querySelector(\"[data-index='0']\").getBoundingClientRect().height");
+
+ // Act
+ await StartSamplingPaintedFramesAsync();
+ await _fixture.Page.EvaluateAsync(
+ @"() => requestAnimationFrame(() => {
+ const content = document.querySelector(""[data-index='0']"").firstElementChild;
+ content.style.minHeight = `${content.getBoundingClientRect().height + 120}px`;
+ })");
+ var frames = await CollectPaintedFramesAsync();
+
+ // Assert
+ Assert.Contains(frames, frame => frame.Rows.Any(row => row.Index == 0 && row.Bottom - row.Top > heightBefore + 100));
+ AssertNoVisibleRowsOverlap(frames);
+ _fixture.AssertNoJsErrors();
+ }
+
+ ///
+ /// Expanding a group inserts rows, which moves every following item to another index. Until fresh placements
+ /// came back from .NET those rows were hidden, so the list below the group went blank for a whole round trip.
+ ///
+ [Fact]
+ public async Task ExpandingAGroup_UnderSlowInterop_ShowsTheRowsBelowBeforeDotNetAnswers()
+ {
+ // Arrange
+ await _fixture.NavigateToStoryAsync("components-adaptivevirtualize--expand-and-collapse");
+ await HoldBackPlacementsAsync();
+
+ // Act
+ await StartSamplingPaintedFramesAsync();
+ await _fixture.Page.ClickAsync("[data-index='0'] .row-group");
+ var frames = await CollectPaintedFramesAsync();
+
+ // Assert
+ Assert.Contains(frames, frame => frame.Rows.Any(row => row.IsChild && !row.IsHidden));
+ // One frame is allowed: the render that hides the moved rows can be painted before setItems reaches JS.
+ var framesWithHiddenRows = frames.Count(frame => frame.Rows.Any(row => row.IsHidden));
+ Assert.True(framesWithHiddenRows <= 1, $"{framesWithHiddenRows} of {frames.Count} painted frames had hidden rows.");
+ AssertNoVisibleRowsOverlap(frames);
+ _fixture.AssertNoJsErrors();
+ }
+
+ ///
+ /// Delays every OnVirtualItemsChanged call, keeping their order, the way a slow WebView bridge does. Only that
+ /// direction is held back: it is the one placements travel, while calls from .NET into JS stay prompt.
+ ///
+ private Task HoldBackPlacementsAsync()
+ {
+ return _fixture.Page.EvaluateAsync(
+ @"delayMs => {
+ const prototype = DotNet.DotNetObject.prototype;
+ const invoke = prototype.invokeMethodAsync;
+ let previous = Promise.resolve();
+ prototype.invokeMethodAsync = function (name, ...args) {
+ if (name !== 'OnVirtualItemsChanged') {
+ return invoke.call(this, name, ...args);
+ }
+ const call = previous
+ .then(() => new Promise(resolve => setTimeout(resolve, delayMs)))
+ .then(() => invoke.call(this, name, ...args));
+ previous = call.catch(() => {});
+ return call;
+ };
+ }",
+ PlacementDelayMilliseconds);
+ }
+
+ ///
+ /// Records every rendered row once per frame, after the frame has been painted: a task queued from a
+ /// requestAnimationFrame callback runs only once that frame's layout, ResizeObserver callbacks and paint are
+ /// done, so it sees what the user saw rather than an intermediate layout.
+ ///
+ private Task StartSamplingPaintedFramesAsync()
+ {
+ return _fixture.Page.EvaluateAsync(
+ @"durationMs => {
+ const frames = [];
+ window.__paintedFrames = new Promise(resolve => {
+ const startedAt = performance.now();
+ const sample = () => frames.push({
+ rows: Array.from(document.querySelectorAll('[data-index]')).map(el => {
+ const rect = el.getBoundingClientRect();
+ return {
+ index: parseInt(el.getAttribute('data-index'), 10),
+ top: rect.top,
+ bottom: rect.bottom,
+ isHidden: getComputedStyle(el).visibility === 'hidden',
+ isChild: el.querySelector('.row-child') !== null,
+ };
+ }),
+ });
+ const tick = () => {
+ setTimeout(sample, 0);
+ if (performance.now() - startedAt < durationMs) {
+ requestAnimationFrame(tick);
+ } else {
+ setTimeout(() => resolve(frames), 50);
+ }
+ };
+ requestAnimationFrame(tick);
+ });
+ }",
+ SampleDurationMilliseconds);
+ }
+
+ private async Task> CollectPaintedFramesAsync()
+ {
+ return await _fixture.Page.EvaluateAsync("() => window.__paintedFrames");
+ }
+
+ private static void AssertNoVisibleRowsOverlap(IReadOnlyList frames)
+ {
+ foreach (var (frameIndex, frame) in frames.Index())
+ {
+ var visibleRows = frame.Rows
+ .Where(row => !row.IsHidden && row.Bottom > row.Top)
+ .OrderBy(row => row.Top)
+ .ToList();
+ for (var index = 1; index < visibleRows.Count; index++)
+ {
+ var previous = visibleRows[index - 1];
+ var current = visibleRows[index];
+ Assert.True(
+ current.Top >= previous.Bottom - OverlapTolerancePx,
+ $"Frame {frameIndex}: row {current.Index} at {current.Top:F1}px overlaps row {previous.Index}, which ends at {previous.Bottom:F1}px.");
+ }
+ }
+ }
+
+ private sealed class PaintedFrame
+ {
+ [JsonPropertyName("rows")]
+ public PaintedRow[] Rows { get; set; } = [];
+ }
+
+ private sealed class PaintedRow
+ {
+ [JsonPropertyName("index")]
+ public int Index { get; set; }
+
+ [JsonPropertyName("top")]
+ public double Top { get; set; }
+
+ [JsonPropertyName("bottom")]
+ public double Bottom { get; set; }
+
+ [JsonPropertyName("isHidden")]
+ public bool IsHidden { get; set; }
+
+ [JsonPropertyName("isChild")]
+ public bool IsChild { get; set; }
+ }
+}
diff --git a/tests/Blazor.TanStack.VirtualCore.Tests/AdaptiveVirtualizeTests.cs b/tests/Blazor.TanStack.VirtualCore.Tests/AdaptiveVirtualizeTests.cs
index 94ae0b9..1a5d8a1 100644
--- a/tests/Blazor.TanStack.VirtualCore.Tests/AdaptiveVirtualizeTests.cs
+++ b/tests/Blazor.TanStack.VirtualCore.Tests/AdaptiveVirtualizeTests.cs
@@ -96,7 +96,7 @@ await component.InvokeAsync(() => component.Instance.OnVirtualItemsChanged(
}
[Fact]
- public void Render_KeyMismatchBetweenPlacementAndItem_SkipsThatRow()
+ public void Render_KeyMismatchBetweenPlacementAndItem_RendersThatRowHidden()
{
// Arrange
using var context = CreateContext();
@@ -113,9 +113,116 @@ public void Render_KeyMismatchBetweenPlacementAndItem_SkipsThatRow()
totalSize: 20)).Wait();
// Assert
- // Rendering the stale placement would visibly misplace the row until the next JS round-trip.
- component.Markup.ShouldContain(">a<");
- component.Markup.ShouldNotContain(">b<");
+ // Painting the stale placement would visibly misplace the row until the next JS round-trip,
+ // so it is rendered - its component exists and can be measured - but not shown.
+ component.Find("[data-index='0']").GetAttribute("style").ShouldNotContain("visibility:hidden");
+ component.Find("[data-index='1']").GetAttribute("style").ShouldContain("visibility:hidden");
+ component.Find("[data-index='1']").TextContent.ShouldBe("b");
+ }
+
+ [Fact]
+ public void Render_FreshPlacementForAHiddenRow_ShowsIt()
+ {
+ // Arrange
+ using var context = CreateContext();
+ var component = RenderWith(context, ["a", "b"]);
+ component.InvokeAsync(() => component.Instance.OnVirtualItemsChanged(
+ [new VirtualItem { Index = 0, Start = 0, Size = 10, End = 10, Key = "stale-key" }],
+ totalSize: 10)).Wait();
+
+ // Act
+ component.InvokeAsync(() => component.Instance.OnVirtualItemsChanged(
+ [new VirtualItem { Index = 0, Start = 0, Size = 10, End = 10, Key = "a" }],
+ totalSize: 10)).Wait();
+
+ // Assert
+ component.Find("[data-index='0']").GetAttribute("style").ShouldNotContain("visibility:hidden");
+ }
+
+ ///
+ /// The JS side moves and shows a row before .NET answers only when the key the row renders is the
+ /// key it holds for that index, so a hidden row has to carry the key of the item it actually renders,
+ /// not the key of the stale placement it was drawn from.
+ ///
+ [Fact]
+ public void Render_KeyMismatchBetweenPlacementAndItem_RowCarriesTheItemKey()
+ {
+ // Arrange
+ using var context = CreateContext();
+ var component = RenderWith(context, ["a", "b"]);
+
+ // Act
+ component.InvokeAsync(() => component.Instance.OnVirtualItemsChanged(
+ [
+ new VirtualItem { Index = 0, Start = 0, Size = 10, End = 10, Key = "a" },
+ new VirtualItem { Index = 1, Start = 10, Size = 10, End = 20, Key = "stale-key" },
+ ],
+ totalSize: 20)).Wait();
+
+ // Assert
+ component.Find("[data-index='0']").GetAttribute("data-key").ShouldBe("a");
+ component.Find("[data-index='1']").GetAttribute("data-key").ShouldBe("b");
+ }
+
+ ///
+ /// A filter or an inserted row changes the item set, and until JS sends fresh placements every
+ /// index can carry a different key. Skipping those rows rendered none, so every row component -
+ /// its videos, images and JS state - was disposed and built again one round trip later, even the
+ /// rows that were in both sets. On an Android WebView that was ~350 components per keystroke.
+ ///
+ [Fact]
+ public void ItemsChange_RowWhoseKeyStays_KeepsItsComponent()
+ {
+ // Arrange
+ using var context = CreateContext();
+ var initializedCount = new InitializedCount();
+ var component = context.RenderComponent>(parameters => parameters
+ .Add(p => p.Items, ["a", "b", "c"])
+ .Add(p => p.ItemKeyExtractor, (Func) (x => x))
+ .Add(p => p.ItemContent, (RenderFragment) (item => builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(InitializationCountingRow.Count), initializedCount);
+ builder.CloseComponent();
+ })));
+ component.InvokeAsync(() => component.Instance.OnVirtualItemsChanged(
+ [
+ new VirtualItem { Index = 0, Start = 0, Size = 10, End = 10, Key = "a" },
+ new VirtualItem { Index = 1, Start = 10, Size = 10, End = 20, Key = "b" },
+ new VirtualItem { Index = 2, Start = 20, Size = 10, End = 30, Key = "c" },
+ ],
+ totalSize: 30)).Wait();
+ var initializedBeforeChange = initializedCount.Value;
+
+ // Act
+ // "a" filtered out: "b" and "c" move up one index each, so the placements JS last sent no
+ // longer match any of them until it sends the next ones.
+ component.SetParametersAndRender(parameters => parameters.Add(p => p.Items, ["b", "c"]));
+ component.InvokeAsync(() => component.Instance.OnVirtualItemsChanged(
+ [
+ new VirtualItem { Index = 0, Start = 0, Size = 10, End = 10, Key = "b" },
+ new VirtualItem { Index = 1, Start = 10, Size = 10, End = 20, Key = "c" },
+ ],
+ totalSize: 20)).Wait();
+
+ // Assert
+ initializedBeforeChange.ShouldBe(3);
+ initializedCount.Value.ShouldBe(3);
+ }
+
+ private sealed class InitializedCount
+ {
+ public int Value { get; set; }
+ }
+
+ private sealed class InitializationCountingRow : ComponentBase
+ {
+ [Parameter] public InitializedCount Count { get; set; } = null!;
+
+ protected override void OnInitialized()
+ {
+ Count.Value++;
+ }
}
[Fact]