From f249aa374217269df0dca021fc5efd7ad44f494a Mon Sep 17 00:00:00 2001 From: Kebechet Date: Mon, 2 Nov 2026 19:50:45 +0100 Subject: [PATCH 1/4] Send setItems only the keys the JS side does not hold yet Every change of Items sent the whole key and size list across the interop bridge again. In a Blazor Hybrid app each call is serialized to JSON and copied through the platform's WebView bridge, so a search over 4,010 rows resent ~94K characters per keystroke, and going back to the full list resent all of them once more. Both sides now keep a table of the (key, size estimate) pairs already sent. A change travels as slot ids, with runs of consecutive ids packed into two numbers, plus only the pairs JS has never received; an unchanged set sends a null order. The JS side rebuilds the arrays and runs the existing setItems, so per-key measurements survive a change as before. The table is rebuilt when it outgrows twice the largest set since the last rebuild, and after a failed call. For the picker's typing sequence the setItems payload drops from 293,225 to 21,170 characters; returning to the full list costs 23. --- .../AdaptiveVirtualize.razor.cs | 26 +- .../ItemSlotTable.cs | 156 +++++++ .../wwwroot/adaptive-virtualize-interop.js | 41 +- .../InteropModuleTests.cs | 80 ++++ .../VirtualizationTests.cs | 47 +++ .../ItemSetInteropTests.cs | 392 ++++++++++++++++++ 6 files changed, 735 insertions(+), 7 deletions(-) create mode 100644 src/Blazor.TanStack.VirtualCore/ItemSlotTable.cs create mode 100644 tests/Blazor.TanStack.VirtualCore.Tests/ItemSetInteropTests.cs diff --git a/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs b/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs index 13c5438..4d6fe9b 100644 --- a/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs +++ b/src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs @@ -97,6 +97,9 @@ public partial class AdaptiveVirtualize : IAsyncDisposable private string[] _itemKeys = []; private double[] _itemSizes = []; + /// What the JS side already holds of the item set, so a change sends it only the rest. + private readonly ItemSlotTable _itemSlots = new(); + private readonly List _preparedRows = []; private IReadOnlyList? _preparedFromVirtualItems; private IReadOnlyList? _preparedFromItems; @@ -247,6 +250,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _lastItemsRef = Items; _lastOptionsRef = Options; BuildDescriptors(); + _itemSlots.Reset(_itemKeys, _itemSizes); _jsInstance = await _jsModule.InvokeAsync( "create", _rootElement, _selfRef, _itemKeys, _itemSizes, EstimatedItemSize, Options); } @@ -310,7 +314,21 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // setItems. _lastItemsRef = Items; BuildDescriptors(); - await InvokeInstance("setItems", _itemKeys, _itemSizes); + var itemSetUpdate = _itemSlots.Encode(_itemKeys, _itemSizes); + try + { + await InvokeInstance( + "setItems", + itemSetUpdate.Order, + itemSetUpdate.NewKeys, + itemSetUpdate.NewSizes, + itemSetUpdate.IsTableReset); + } + catch + { + _itemSlots.Invalidate(); + throw; + } if (_isDisposed) { @@ -524,8 +542,10 @@ private async Task InvokeInstance(string method, params object?[] args) /// /// Snapshots into the two arrays the JS side needs to answer the - /// virtualizer's getItemKey and estimateSize callbacks synchronously. Sent once - /// per reference change - never on scroll. + /// virtualizer's getItemKey and estimateSize callbacks synchronously. Rebuilt + /// once per reference change - never on scroll. They cross whole only when + /// the JS instance is created; a later change sends what finds the + /// JS side does not hold yet. /// /// /// Two parallel arrays rather than one array of objects, because this crosses the interop diff --git a/src/Blazor.TanStack.VirtualCore/ItemSlotTable.cs b/src/Blazor.TanStack.VirtualCore/ItemSlotTable.cs new file mode 100644 index 0000000..ef8245f --- /dev/null +++ b/src/Blazor.TanStack.VirtualCore/ItemSlotTable.cs @@ -0,0 +1,156 @@ +namespace Kebechet.Blazor.TanStack.VirtualCore; + +/// +/// The .NET half of the slot table the interop module keeps: every distinct (key, size estimate) +/// pair it has been sent, numbered in the order it arrived. An item-set change then travels as slot +/// ids plus only the pairs the JS side does not hold yet, instead of as every key again. +/// +/// +/// +/// The JS side needs the whole set - it answers virtual-core's getItemKey synchronously and +/// carries measurements across a change by key - but what it already holds need not cross again. In +/// a Blazor Hybrid app every interop call is serialized to JSON and copied through the platform's +/// WebView bridge, and a search over a few thousand items used to resend all of them per keystroke. +/// +/// +/// A slot is a key together with its estimate, not the key alone, so an estimate that changes for a +/// key, or two items sharing a key but not an estimate, reach the JS side exactly as before. +/// +/// +/// In a non-negative entry is a slot id and a negative entry +/// -n continues the previous slot with the next n, so a stretch of items in the order +/// they were first sent - typically the whole unfiltered list - costs two numbers. +/// +/// +internal sealed class ItemSlotTable +{ + /// + /// Slots allowed per item of the largest set since the table was last rebuilt. A key that leaves + /// the set keeps its slot, so a list whose keys keep changing - a live feed - would grow the table + /// without bound; past this it is rebuilt from the current set, which costs one call carrying + /// every key, as every call used to. + /// + private const int _maxSlotsPerItem = 2; + + private readonly Dictionary<(string Key, double Size), int> _slotIdsByEntry = []; + private readonly List _newKeys = []; + private readonly List _newSizes = []; + private List _order = []; + private List _sentOrder = []; + private int _slotCount; + private int _largestItemCount; + private bool _isRebuildRequired; + + /// + /// Starts the table over from arrays the JS side takes whole, as it does those the instance is + /// created from: slot i is item i. + /// + public void Reset(string[] keys, double[] sizes) + { + _slotIdsByEntry.Clear(); + for (var index = 0; index < keys.Length; index++) + { + _slotIdsByEntry.TryAdd((keys[index], sizes[index]), index); + } + + _slotCount = keys.Length; + _largestItemCount = keys.Length; + _isRebuildRequired = false; + _sentOrder.Clear(); + if (keys.Length > 0) + { + _sentOrder.Add(0); + } + + if (keys.Length > 1) + { + _sentOrder.Add(-(keys.Length - 1)); + } + } + + /// + /// Makes the next rebuild the table: a call that failed may or may not have + /// reached the JS side, so the slots it added cannot be referred to. + /// + public void Invalidate() + { + _isRebuildRequired = true; + } + + /// The arguments of the interop module's setItems that turn its item set into this one. + public ItemSetUpdate Encode(string[] keys, double[] sizes) + { + _largestItemCount = Math.Max(_largestItemCount, keys.Length); + if (!_isRebuildRequired) + { + EncodeAgainstTable(keys, sizes); + if (_slotCount <= _maxSlotsPerItem * _largestItemCount) + { + if (_order.SequenceEqual(_sentOrder)) + { + return new ItemSetUpdate(null, _newKeys, _newSizes, false); + } + + (_sentOrder, _order) = (_order, _sentOrder); + return new ItemSetUpdate(_sentOrder, _newKeys, _newSizes, false); + } + } + + Reset(keys, sizes); + return new ItemSetUpdate(_sentOrder, keys, sizes, true); + } + + private void EncodeAgainstTable(string[] keys, double[] sizes) + { + _order.Clear(); + _newKeys.Clear(); + _newSizes.Clear(); + var runLength = 0; + var previousSlot = -1; + for (var index = 0; index < keys.Length; index++) + { + var entry = (keys[index], sizes[index]); + if (!_slotIdsByEntry.TryGetValue(entry, out var slot)) + { + slot = _slotCount++; + _slotIdsByEntry.Add(entry, slot); + _newKeys.Add(keys[index]); + _newSizes.Add(sizes[index]); + } + + if (index > 0 && slot == previousSlot + 1) + { + runLength++; + } + else + { + EndRun(); + _order.Add(slot); + } + + previousSlot = slot; + } + + EndRun(); + + void EndRun() + { + if (runLength > 0) + { + _order.Add(-runLength); + runLength = 0; + } + } + } +} + +/// The arguments of the interop module's setItems; see . +/// The set as slot ids, or null when it is the set the JS side already holds. +/// Keys of the slots appended before is read. +/// Size estimates of those slots. +/// Whether the JS side drops its table before appending. +internal readonly record struct ItemSetUpdate( + IReadOnlyList? Order, + IReadOnlyList NewKeys, + IReadOnlyList NewSizes, + bool IsTableReset); diff --git a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js index d3c39b3..8248dcc 100644 --- a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js +++ b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js @@ -51,14 +51,19 @@ const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resol 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 on - // every item-set change. For a few thousand items the object form cost that many allocations on - // the .NET side and a much larger payload. + // WHOLE item set, not just the rendered window, and it crosses the interop boundary as JSON. For + // a few thousand items the object form cost that many allocations on the .NET side and a much + // larger payload. constructor(rootElement, dotNetRef, itemKeys, itemSizes, estimatedItemSize, options) { this.rootElement = rootElement; this.dotNetRef = dotNetRef; this.itemKeys = itemKeys ?? []; this.itemSizes = itemSizes ?? []; + // Every (key, size) pair sent so far, numbered in the order it arrived, which ItemSlotTable + // mirrors on the .NET side so that setItems can send slot ids instead of keys. The arrays the + // instance is created from are its first slots, slot i being item i. + this.slotKeys = [...this.itemKeys]; + this.slotSizes = [...this.itemSizes]; this.estimatedItemSize = estimatedItemSize; this.options = compact(options); this.scrollContainer = this.findScrollContainer(rootElement); @@ -384,7 +389,35 @@ class AdaptiveVirtualizeInstance { this.notifyChange(); } - setItems(newItemKeys, newItemSizes) { + /** + * Replaces the item set. It arrives as slots rather than keys - see ItemSlotTable on the .NET + * side: the slots this call appends to the table (dropping the table first on isTableReset), + * then the set as slot ids, where a negative entry -n continues the previous slot with the next + * n. A null order is the set this instance already holds. + */ + setItems(order, newSlotKeys, newSlotSizes, isTableReset) { + if (isTableReset) { + this.slotKeys = []; + this.slotSizes = []; + } + for (let index = 0; index < newSlotKeys.length; index++) { + this.slotKeys.push(newSlotKeys[index]); + this.slotSizes.push(newSlotSizes[index]); + } + + const newItemKeys = order === null ? this.itemKeys : []; + const newItemSizes = order === null ? this.itemSizes : []; + let slot = -1; + for (const entry of order ?? []) { + const firstSlot = entry < 0 ? slot + 1 : entry; + const lastSlot = entry < 0 ? slot - entry : entry; + for (slot = firstSlot; slot <= lastSlot; slot++) { + newItemKeys.push(this.slotKeys[slot]); + newItemSizes.push(this.slotSizes[slot]); + } + slot = lastSlot; + } + // Snapshot existing per-key measurements before measure() wipes them. An item-set change // (a filter swap, an inserted child row) must not cost the measurements of items whose // content did not change - otherwise expanding a group clears the group's measured size, diff --git a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs index a7b8a2a..202e6ee 100644 --- a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs +++ b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs @@ -288,6 +288,86 @@ public async Task ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentum _fixture.AssertNoJsErrors(); } + [Fact] + public async Task SetItems_ReorderedSubsetThenTheFullListAgain_KeepsTheSizesMeasuredForKeysThatStayed() + { + // Arrange + await _fixture.NavigateToStoryAsync(_story); + + // Act + var samples = await _fixture.Page.EvaluateAsync( + "async () => {" + _mountScript + @" + // Twenty 50px items, row-i in slot i. Rows 0-5 are measured at real, distinct heights, + // none of them 50, which virtual-core would not record as a change. + const list = mount(20, {}); + const rows = [80, 30, 60, 40, 70, 20].map((height, index) => { + const row = document.createElement('div'); + row.setAttribute('data-index', String(index)); + row.style.height = `${height}px`; + list.root.appendChild(row); + return row; + }); + list.instance.measureRenderedRows(); + rows.forEach(row => row.remove()); + + // Read through getMeasurements rather than takeSnapshot: takeSnapshot reads virtual-core's + // measurementsCache field, which 3.19 only refreshes when getMeasurements runs. + const read = () => { + const measurements = list.instance.virtualizer.getMeasurements(); + return { keys: measurements.map(x => String(x.key)), sizes: measurements.map(x => x.size) }; + }; + const samples = []; + + // Slots 4, 2 and 0, a key never sent before - slot 20 - then slots 10 to 12 as a run. + list.instance.setItems([4, 2, 0, 20, 10, -2], ['fresh'], [33], false); + samples.push(read()); + + // Back to the list as it was first sent: one run over slots 0 to 19. + list.instance.setItems([0, -19], [], [], false); + samples.push(read()); + + // The same set again, which .NET sends without an order. + list.instance.setItems(null, [], [], false); + samples.push(read()); + + // A rebuilt table holds only what the rebuilding call sent. + list.instance.setItems([1, 0], ['row-2', 'other'], [50, 45], true); + samples.push(read()); + + list.instance.dispose(); + list.viewport.remove(); + return samples; + }"); + + // Assert + // The subset keeps the measured rows it contains and estimates the rest. + Assert.Equal(["row-4", "row-2", "row-0", "fresh", "row-10", "row-11", "row-12"], samples[0].Keys); + Assert.Equal([70, 60, 80, 33, 50, 50, 50], samples[0].Sizes); + + // Measurements survive per key, so rows 0, 2 and 4 come back measured. Rows 1, 3 and 5 left + // the set in between, which evicted theirs. + double[] fullListSizes = [80, 50, 60, 50, 70, .. Enumerable.Repeat(50d, 15)]; + Assert.Equal(Enumerable.Range(0, 20).Select(x => $"row-{x}"), samples[1].Keys); + Assert.Equal(fullListSizes, samples[1].Sizes); + + Assert.Equal(samples[1].Keys, samples[2].Keys); + Assert.Equal(fullListSizes, samples[2].Sizes); + + // The slot table is only how the set travels; row-2's measurement is held by key and survives it. + Assert.Equal(["other", "row-2"], samples[3].Keys); + Assert.Equal([45, 60], samples[3].Sizes); + _fixture.AssertNoJsErrors(); + } + + private sealed class ItemSetSample + { + [JsonPropertyName("keys")] + public string[] Keys { get; set; } = []; + + [JsonPropertyName("sizes")] + public double[] Sizes { get; set; } = []; + } + private sealed class CommandSample { [JsonPropertyName("overflowWhenIssued")] diff --git a/tests/Blazor.TanStack.VirtualCore.E2E/VirtualizationTests.cs b/tests/Blazor.TanStack.VirtualCore.E2E/VirtualizationTests.cs index 9554037..2056f56 100644 --- a/tests/Blazor.TanStack.VirtualCore.E2E/VirtualizationTests.cs +++ b/tests/Blazor.TanStack.VirtualCore.E2E/VirtualizationTests.cs @@ -224,6 +224,45 @@ await _fixture.Page.WaitForSelectorAsync( _fixture.AssertNoJsErrors(); } + [Fact] + public async Task CollapsingAndReExpandingAGroup_RendersTheSameRowsAgain() + { + // Arrange + await _fixture.NavigateToStoryAsync("components-adaptivevirtualize--expand-and-collapse"); + await _fixture.Page.ClickAsync("[data-index='0'] .row-group"); + await WaitForChildRowCountAsync(3); + await _fixture.Page.ClickAsync("[data-index='0'] .row-group"); + await WaitForChildRowCountAsync(0); + + // Act + // Every row of the re-expanded set has been sent before, so it travels as slot ids alone and + // the JS side has to rebuild the keys from its own table. + await _fixture.Page.ClickAsync("[data-index='0'] .row-group"); + await WaitForChildRowCountAsync(3); + var titles = await _fixture.Page.EvaluateAsync( + @"() => Array.from(document.querySelectorAll('[data-index]')) + .sort((a, b) => parseInt(a.getAttribute('data-index'), 10) - parseInt(b.getAttribute('data-index'), 10)) + .slice(0, 5) + .map(el => el.querySelector('.row-title').textContent)"); + var rows = await _fixture.ReadRenderedRowsAsync(); + + // Assert + // A key rebuilt wrongly fails the component's key check, and that row is not rendered at all. + Assert.EndsWith("Group 0", titles[0]); + Assert.Equal(["Child 0.0", "Child 0.1", "Child 0.2"], titles[1..4]); + Assert.EndsWith("Group 1", titles[4]); + for (var index = 1; index < rows.Count; index++) + { + var previous = rows[index - 1]; + var current = rows[index]; + Assert.True( + current.Offset(false) >= previous.Offset(false) + previous.Height - 1.5, + $"Row {current.Index} at {current.Offset(false):F1}px overlaps row {previous.Index}, which ends at {previous.Offset(false) + previous.Height:F1}px."); + } + + _fixture.AssertNoJsErrors(); + } + [Fact] public async Task LayoutMeasuredCallback_FiresAfterMeasurement() { @@ -243,4 +282,12 @@ public async Task LayoutMeasuredCallback_FiresAfterMeasurement() Assert.True(StoryState.Int(state, "measuredPasses") > 0); _fixture.AssertNoJsErrors(); } + + private async Task WaitForChildRowCountAsync(int count) + { + await _fixture.Page.WaitForFunctionAsync( + "count => document.querySelectorAll('.row-child').length === count", + count, + new Microsoft.Playwright.PageWaitForFunctionOptions { Timeout = 15_000 }); + } } diff --git a/tests/Blazor.TanStack.VirtualCore.Tests/ItemSetInteropTests.cs b/tests/Blazor.TanStack.VirtualCore.Tests/ItemSetInteropTests.cs new file mode 100644 index 0000000..1840895 --- /dev/null +++ b/tests/Blazor.TanStack.VirtualCore.Tests/ItemSetInteropTests.cs @@ -0,0 +1,392 @@ +using System.Text.Json; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Microsoft.JSInterop.Infrastructure; +using Shouldly; +using Xunit; +using TestContext = Bunit.TestContext; + +namespace Kebechet.Blazor.TanStack.VirtualCore.Tests; + +/// +/// What an item-set change sends across interop. Every call is serialized by Blazor's own +/// , so the payloads asserted on are the bytes a Blazor Hybrid app pushes +/// through its WebView bridge, and replays them the way the interop module +/// does to check the JS side still ends up with exactly the item set the component holds. +/// +public class ItemSetInteropTests +{ + private const double _estimatedItemSize = 50; + + private static readonly string[] _rowKeys = Enumerable.Range(0, 100) + .Select(x => $"row-{x}") + .ToArray(); + + [Fact] + public void ItemsChange_NewListWithTheSameContent_SendsNoKeys() + { + // Arrange + using var context = new TestContext(); + var recorder = Register(context); + var component = RenderWith(context, recorder, _rowKeys); + + // Act + SetItems(component, recorder, _rowKeys.ToArray()); + + // Assert + var payload = recorder.LastArguments("setItems"); + payload.ShouldNotContain("\"row-"); + payload.Length.ShouldBeLessThan(40); + JsItemSet.Replay(recorder).Keys.ShouldBe(_rowKeys); + } + + [Fact] + public void ItemsChange_ReorderedSubset_SendsNoKeysTheJSSideAlreadyHolds() + { + // Arrange + using var context = new TestContext(); + var recorder = Register(context); + var component = RenderWith(context, recorder, _rowKeys); + // What a relevance-ranked search returns: some of the items, not in list order. + var subset = _rowKeys + .Where((_, index) => index % 3 == 0) + .Reverse() + .ToArray(); + + // Act + SetItems(component, recorder, subset); + + // Assert + recorder.LastArguments("setItems").ShouldNotContain("\"row-"); + JsItemSet.Replay(recorder).Keys.ShouldBe(subset); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItemsChange_BackToAnEarlierList_SendsNoKeys(bool isSameInstance) + { + // Arrange + using var context = new TestContext(); + var recorder = Register(context); + var component = RenderWith(context, recorder, _rowKeys); + SetItems(component, recorder, _rowKeys.Take(10).Reverse().ToArray()); + + // Act + // Clearing a search: the app hands back either the list it started from or a copy of it. + SetItems(component, recorder, isSameInstance ? _rowKeys : _rowKeys.ToArray()); + + // Assert + var payload = recorder.LastArguments("setItems"); + payload.ShouldNotContain("\"row-"); + payload.Length.ShouldBeLessThan(40); + JsItemSet.Replay(recorder).Keys.ShouldBe(_rowKeys); + } + + [Fact] + public void ItemsChange_KeysNeverSentBefore_SendsOnlyThoseKeys() + { + // Arrange + using var context = new TestContext(); + var recorder = Register(context); + var component = RenderWith(context, recorder, _rowKeys); + string[] items = ["row-7", "new-a", "row-3", "row-4", "new-b"]; + + // Act + SetItems(component, recorder, items); + + // Assert + var payload = recorder.LastArguments("setItems"); + payload.ShouldContain("\"new-a\""); + payload.ShouldContain("\"new-b\""); + payload.ShouldNotContain("\"row-"); + JsItemSet.Replay(recorder).Keys.ShouldBe(items); + } + + [Fact] + public void ItemsChange_EstimateChangesForAKnownKey_SendsThatItemAloneWithItsNewEstimate() + { + // Arrange + using var context = new TestContext(); + var recorder = Register(context); + var estimates = _rowKeys.ToDictionary(x => x, _ => 60d); + var component = RenderWith(context, recorder, _rowKeys, x => estimates[x]); + + // Act + // A group row expanding: same key, a taller estimate. + estimates["row-5"] = 180; + SetItems(component, recorder, _rowKeys.ToArray()); + + // Assert + var payload = recorder.LastArguments("setItems"); + payload.ShouldContain("\"row-5\""); + payload.Replace("\"row-5\"", string.Empty).ShouldNotContain("\"row-"); + var jsItemSet = JsItemSet.Replay(recorder); + jsItemSet.Keys.ShouldBe(_rowKeys); + jsItemSet.Sizes.ShouldBe(_rowKeys.Select(x => estimates[x])); + } + + [Fact] + public void ItemsChange_SameKeyTwiceWithDifferentEstimates_TheJSSideKeepsBoth() + { + // Arrange + // Duplicate keys break the component's contract, but each position still used to reach the + // JS side with its own estimate, so it still does. + using var context = new TestContext(); + var recorder = Register(context); + var component = RenderWith(context, recorder, ["a", "b"], x => x.StartsWith("tall") ? 200 : 40); + var items = new[] { "a", "tall-b", "b" }; + + // Act + component.SetParametersAndRender(parameters => parameters + .Add(p => p.ItemKeyExtractor, (Func) (x => x.Replace("tall-", string.Empty))) + .Add(p => p.Items, items)); + component.WaitForAssertion(() => recorder.Count("setItems").ShouldBe(1)); + + // Assert + var jsItemSet = JsItemSet.Replay(recorder); + jsItemSet.Keys.ShouldBe(["a", "b", "b"]); + jsItemSet.Sizes.ShouldBe([40, 200, 40]); + } + + [Fact] + public void ItemsChange_KeysKeepChanging_TheJSSideTableStaysBounded() + { + // Arrange + // A rotating window - a live feed - where every set is ten keys never seen before. + const int itemCount = 10; + using var context = new TestContext(); + var recorder = Register(context); + var component = RenderWith(context, recorder, Window(0)); + + // Act + for (var round = 1; round <= 50; round++) + { + SetItems(component, recorder, Window(round)); + } + + // Assert + // Every key ever sent staying in the table would hold 510 by now. + var jsItemSet = JsItemSet.Replay(recorder); + jsItemSet.LargestTableSize.ShouldBeLessThanOrEqualTo(2 * itemCount); + jsItemSet.Keys.ShouldBe(Window(50)); + + static string[] Window(int round) + { + return Enumerable.Range(round * itemCount, itemCount) + .Select(x => $"feed-{x}") + .ToArray(); + } + } + + [Fact] + public void ItemsChange_AfterASetItemsThatFailed_ResendsTheWholeItemSet() + { + // Arrange + using var context = new TestContext(); + var recorder = Register(context); + var component = RenderWith(context, recorder, _rowKeys); + recorder.FailNext("setItems"); + // A failure while the component is alive still throws, as it always has. + var failure = Should.Throw(() => component.SetParametersAndRender(parameters => parameters.Add(p => p.Items, _rowKeys.Take(50).ToArray()))); + + // Act + var items = _rowKeys.Skip(50).Append("new-a").ToArray(); + // bUnit reports that failure once more from the next render, after the render has run. + var exception = Record.Exception(() => component.SetParametersAndRender(parameters => parameters.Add(p => p.Items, items))); + + // Assert + (exception is null || ReferenceEquals(exception, failure)).ShouldBeTrue(); + recorder.FailedCount.ShouldBe(1); + recorder.Count("setItems").ShouldBe(1); + // The failed call may never have reached the JS side, so nothing it added can be referred to. + recorder.LastArguments("setItems").ShouldContain("\"row-99\""); + JsItemSet.Replay(recorder).Keys.ShouldBe(items); + } + + private static InteropRecorder Register(TestContext context) + { + var recorder = new InteropRecorder(); + context.Services.AddSingleton(recorder); + return recorder; + } + + private static IRenderedComponent> RenderWith( + TestContext context, + InteropRecorder recorder, + IReadOnlyList items, + Func? itemSizeEstimator = null) + { + var component = context.RenderComponent>(parameters => parameters + .Add(p => p.Items, items) + .Add(p => p.ItemKeyExtractor, (Func) (x => x)) + .Add(p => p.ItemSizeEstimator, itemSizeEstimator) + .Add(p => p.EstimatedItemSize, _estimatedItemSize) + .Add(p => p.ItemContent, (RenderFragment) (item => builder => builder.AddContent(0, item)))); + + // The instance is created from an awaited import, after the first render has returned. + component.WaitForAssertion(() => recorder.Count("create").ShouldBe(1)); + return component; + } + + private static void SetItems(IRenderedComponent> component, InteropRecorder recorder, IReadOnlyList items) + { + var setItemsCount = recorder.Count("setItems"); + component.SetParametersAndRender(parameters => parameters.Add(p => p.Items, items)); + component.WaitForAssertion(() => recorder.Count("setItems").ShouldBe(setItemsCount + 1)); + } + + /// + /// A real with the browser replaced: it records the JSON of every call, + /// answers it at once, and hands out object references for import and create. + /// + private sealed class InteropRecorder : JSRuntime + { + private readonly List<(string Identifier, string ArgumentsJson)> _calls = []; + private readonly HashSet _failingIdentifiers = []; + private long _nextObjectId; + + public int FailedCount { get; private set; } + + public IReadOnlyList<(string Identifier, string ArgumentsJson)> Calls + { + get + { + lock (_calls) + { + return _calls.ToArray(); + } + } + } + + /// Fails the next call to , which is then not recorded. + public void FailNext(string identifier) + { + lock (_calls) + { + _failingIdentifiers.Add(identifier); + } + } + + public int Count(string identifier) + { + return Calls.Count(x => x.Identifier == identifier); + } + + public string LastArguments(string identifier) + { + return Calls.Last(x => x.Identifier == identifier).ArgumentsJson; + } + + protected override void BeginInvokeJS(long taskId, string identifier, string? argsJson, JSCallResultType resultType, long targetInstanceId) + { + bool isFailing; + lock (_calls) + { + isFailing = _failingIdentifiers.Remove(identifier); + if (isFailing) + { + FailedCount++; + } + else + { + _calls.Add((identifier, argsJson ?? "[]")); + } + } + + var outcome = isFailing + ? $"false,\"{identifier} failed\"" + : resultType == JSCallResultType.JSObjectReference + ? $"true,{{\"__jsObjectId\":{Interlocked.Increment(ref _nextObjectId)}}}" + : "true,null"; + DotNetDispatcher.EndInvokeJS(this, $"[{taskId},{outcome}]"); + } + + protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) + { + } + } + + /// + /// The item set the interop module ends up holding, rebuilt from the calls it was sent by the same + /// decoding adaptive-virtualize-interop.js does: create seeds a table of (key, size) + /// slots, slot i being item i; setItems(order, newKeys, newSizes, isTableReset) + /// appends the new slots, then reads the set from order, where a negative entry -n + /// continues the previous slot with the next n, and null keeps the set as it is. + /// + private sealed class JsItemSet + { + private readonly List _slotKeys = []; + private readonly List _slotSizes = []; + + public List Keys { get; private set; } = []; + public List Sizes { get; private set; } = []; + public int LargestTableSize { get; private set; } + + public static JsItemSet Replay(InteropRecorder recorder) + { + var jsItemSet = new JsItemSet(); + foreach (var (identifier, argumentsJson) in recorder.Calls) + { + using var arguments = JsonDocument.Parse(argumentsJson); + var root = arguments.RootElement; + switch (identifier) + { + case "create": + jsItemSet.AddSlots(root[2], root[3]); + jsItemSet.Keys = [.. jsItemSet._slotKeys]; + jsItemSet.Sizes = [.. jsItemSet._slotSizes]; + break; + case "setItems": + jsItemSet.ApplySetItems(root); + break; + } + } + + return jsItemSet; + } + + private void ApplySetItems(JsonElement arguments) + { + if (arguments[3].GetBoolean()) + { + _slotKeys.Clear(); + _slotSizes.Clear(); + } + + AddSlots(arguments[1], arguments[2]); + if (arguments[0].ValueKind == JsonValueKind.Null) + { + return; + } + + Keys = []; + Sizes = []; + var previousSlot = -1; + foreach (var entry in arguments[0].EnumerateArray().Select(x => x.GetInt32())) + { + var firstSlot = entry < 0 + ? previousSlot + 1 + : entry; + var slotCount = entry < 0 + ? -entry + : 1; + for (var slot = firstSlot; slot < firstSlot + slotCount; slot++) + { + Keys.Add(_slotKeys[slot]); + Sizes.Add(_slotSizes[slot]); + previousSlot = slot; + } + } + } + + private void AddSlots(JsonElement keys, JsonElement sizes) + { + _slotKeys.AddRange(keys.EnumerateArray().Select(x => x.GetString()!)); + _slotSizes.AddRange(sizes.EnumerateArray().Select(x => x.GetDouble())); + LargestTableSize = Math.Max(LargestTableSize, _slotKeys.Count); + } + } +} From 3ffeeda078e5d82d3f83549910a55fea947e8dc1 Mon Sep 17 00:00:00 2001 From: Kebechet Date: Mon, 2 Nov 2026 20:00:38 +0100 Subject: [PATCH 2/4] Read current measurements in TakeSnapshot, GetOffsetForIndex and ScrollToIndex virtual-core keeps its measurements in a field it only brings up to date when getMeasurements runs. Every notification normally makes that happen, but notifyChange holds back one raised inside a ResizeObserver callback until the next task, and InvalidateItemSize raises none. Until something recomputes, the field still holds sizes the virtualizer has stopped using, so TakeSnapshot handed back a row's old size, GetOffsetForIndex an offset from it, and ScrollToIndex landed its first write on the old offset (520 instead of 490) before jumping a frame later. All three now refresh through getMeasurements first, which is memoized and costs nothing when nothing changed. --- .../wwwroot/adaptive-virtualize-interop.js | 23 ++- .../InteropModuleTests.cs | 145 +++++++++++++++++- 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js index 8248dcc..7759a32 100644 --- a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js +++ b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js @@ -495,7 +495,11 @@ class AdaptiveVirtualizeInstance { } scrollToIndex(index, align, behavior) { - return this.runScrollCommand(() => this.virtualizer.scrollToIndex(index, { align, behavior })); + return this.runScrollCommand(() => { + // virtual-core aims the write through getOffsetForIndex, which reads measurementsCache. + this.refreshMeasurements(); + this.virtualizer.scrollToIndex(index, { align, behavior }); + }); } scrollToOffset(offset, align, behavior) { @@ -586,6 +590,8 @@ class AdaptiveVirtualizeInstance { } getOffsetForIndex(index, align) { + // virtual-core computes the offset from measurementsCache. + this.refreshMeasurements(); // Upstream returns [offset, alignment]; only the offset is meaningful to a caller. const result = this.virtualizer.getOffsetForIndex(index, align); return Array.isArray(result) ? result[0] : result; @@ -621,10 +627,23 @@ class AdaptiveVirtualizeInstance { takeSnapshot() { // measurementsCache is the array shape initialMeasurementsCache expects back, so a snapshot // taken here can be handed straight to a later mount. - const measurements = this.virtualizer.measurementsCache ?? []; + const measurements = this.refreshMeasurements() ?? []; return measurements.map(toPayload); } + /** + * Brings virtual-core's measurementsCache up to date and returns it. The field only catches up + * when getMeasurements runs, which every notification normally makes happen - but notifyChange + * holds back one raised inside a ResizeObserver callback until the next task, and + * invalidateItemSize raises none. Until something does, the field still holds sizes the + * virtualizer has stopped using: a row a ResizeObserver has just re-measured, or one whose + * measurement was just evicted. getMeasurements is memoized, so this costs nothing when nothing + * changed. + */ + refreshMeasurements() { + return this.virtualizer.getMeasurements(); + } + dispose() { this.containerObserver?.disconnect(); clearTimeout(this.pendingNotification); diff --git a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs index 202e6ee..8b48bed 100644 --- a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs +++ b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs @@ -41,6 +41,33 @@ public sealed class InteropModuleTests return list; };"; + /// + /// Appended to by tests that need measured rows. mountMeasured + /// mounts a list with 2000px to scroll in, then renders rows 0-2 at 80, 30 and 60px and measures + /// them as a Blazor render and measureRenderedRows do. virtual-core observes each row's + /// size from then on. + /// + private const string _measuredRowsScript = @" + const mountMeasured = async (itemCount, options) => { + const list = mount(itemCount, options); + list.root.style.height = '2000px'; + // Measured a frame later, as in the app. In the constructor's own task a notification is + // still held back, which would hide the measurement from whatever reads it. + await nextFrame(); + await nextFrame(); + list.rows = [80, 30, 60].map((height, index) => { + const row = document.createElement('div'); + row.setAttribute('data-index', String(index)); + row.style.height = `${height}px`; + list.root.appendChild(row); + return row; + }); + list.instance.measureRenderedRows(); + await nextFrame(); + await nextFrame(); + return list; + };"; + private readonly DemoFixture _fixture; public InteropModuleTests(DemoFixture fixture) @@ -310,11 +337,9 @@ public async Task SetItems_ReorderedSubsetThenTheFullListAgain_KeepsTheSizesMeas list.instance.measureRenderedRows(); rows.forEach(row => row.remove()); - // Read through getMeasurements rather than takeSnapshot: takeSnapshot reads virtual-core's - // measurementsCache field, which 3.19 only refreshes when getMeasurements runs. const read = () => { - const measurements = list.instance.virtualizer.getMeasurements(); - return { keys: measurements.map(x => String(x.key)), sizes: measurements.map(x => x.size) }; + const measurements = list.instance.takeSnapshot(); + return { keys: measurements.map(x => x.key), sizes: measurements.map(x => x.size) }; }; const samples = []; @@ -359,6 +384,118 @@ public async Task SetItems_ReorderedSubsetThenTheFullListAgain_KeepsTheSizesMeas _fixture.AssertNoJsErrors(); } + [Fact] + public async Task TakeSnapshot_RightAfterAMeasurementChanges_ReportsTheSizesTheVirtualizerUses() + { + // Arrange + await _fixture.NavigateToStoryAsync(_story); + + // Act + var samples = await _fixture.Page.EvaluateAsync( + "async () => {" + _mountScript + _measuredRowsScript + @" + // With useAnimationFrameWithResizeObserver virtual-core measures a frame later, + // after the observer below has already run. + const list = await mountMeasured(20, { useAnimationFrameWithResizeObserver: false }); + const sizes = () => list.instance.takeSnapshot().slice(0, 3).map(x => x.size); + + // An observer created later on the same element is called straight after + // virtual-core's, which has measured the row by then. The notification about that + // measurement goes out a task later. + const insideObserver = await new Promise(resolve => { + new ResizeObserver((_, observer) => { + observer.disconnect(); + resolve(sizes()); + }).observe(list.rows[1]); + list.rows[1].style.height = '90px'; + }); + await nextFrame(); + await nextFrame(); + + // invalidateItemSize evicts a measurement without notifying at all. + list.instance.invalidateItemSize('row-0'); + const afterInvalidation = sizes(); + + list.instance.dispose(); + list.viewport.remove(); + return [insideObserver, afterInvalidation]; + }"); + + // Assert + // The snapshot exists to be restored on a later mount, which would then place rows by sizes + // this list had already stopped using. + Assert.Equal([80, 90, 60], samples[0]); + Assert.Equal([50, 90, 60], samples[1]); + _fixture.AssertNoJsErrors(); + } + + [Fact] + public async Task GetOffsetForIndex_RightAfterARowAboveChanges_UsesTheSizesTheVirtualizerUses() + { + // Arrange + await _fixture.NavigateToStoryAsync(_story); + + // Act + var offsets = await _fixture.Page.EvaluateAsync( + "async () => {" + _mountScript + _measuredRowsScript + @" + const list = await mountMeasured(20, { useAnimationFrameWithResizeObserver: false }); + const offsetOfRow3 = () => list.instance.getOffsetForIndex(3, 'start'); + + const insideObserver = await new Promise(resolve => { + new ResizeObserver((_, observer) => { + observer.disconnect(); + resolve(offsetOfRow3()); + }).observe(list.rows[1]); + list.rows[1].style.height = '90px'; + }); + await nextFrame(); + await nextFrame(); + + list.instance.invalidateItemSize('row-0'); + const afterInvalidation = offsetOfRow3(); + + list.instance.dispose(); + list.viewport.remove(); + return [insideObserver, afterInvalidation]; + }"); + + // Assert + // Rows 0-2 at 80 + 90 + 60, then with row 0 back at its 50px estimate. + Assert.Equal([230, 200], offsets); + _fixture.AssertNoJsErrors(); + } + + [Fact] + public async Task ScrollToIndex_RightAfterInvalidateItemSize_FirstWriteLandsOnTheTarget() + { + // Arrange + await _fixture.NavigateToStoryAsync(_story); + + // Act + var scrollTops = await _fixture.Page.EvaluateAsync( + "async () => {" + _mountScript + _measuredRowsScript + @" + const list = await mountMeasured(40, {}); + + // Evicting row 0's 80px measurement moves row 10 from 520 up to 490. + list.instance.invalidateItemSize('row-0'); + list.instance.scrollToIndex(10, 'start', 'auto'); + const firstWrite = list.viewport.scrollTop; + await nextFrame(); + await nextFrame(); + const settled = list.viewport.scrollTop; + + list.instance.dispose(); + list.viewport.remove(); + return [firstWrite, settled]; + }"); + + // Assert + // virtual-core re-aims a frame later anyway, so a first write at the old offset shows as a + // jump of the evicted difference - in the toggle-a-group-then-scroll-to-it flow + // InvalidateItemSize exists for. + Assert.Equal([490, 490], scrollTops); + _fixture.AssertNoJsErrors(); + } + private sealed class ItemSetSample { [JsonPropertyName("keys")] From b8cd1a82756b7414cc1528d36cf709fb92b6be54 Mon Sep 17 00:00:00 2001 From: Kebechet Date: Mon, 2 Nov 2026 20:39:10 +0100 Subject: [PATCH 3/4] Report the current range in GetSnapshot GetSnapshot read StartIndex and EndIndex from virtual-core's range field, which lags exactly as the measurements did: only calculateRange assigns it, so inside a ResizeObserver delivery it still reported the rows before the resize (0-3 where the virtualizer already used 0-0), and after InvalidateItemSize the range before the eviction. It now asks calculateRange, which is memoized and refreshes the measurements too. Also names TakeSnapshot correctly in the InitialMeasurementsCache doc. --- .../VirtualizeOptions.cs | 2 +- .../wwwroot/adaptive-virtualize-interop.js | 6 ++- .../InteropModuleTests.cs | 45 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/Blazor.TanStack.VirtualCore/VirtualizeOptions.cs b/src/Blazor.TanStack.VirtualCore/VirtualizeOptions.cs index d129777..6191ab5 100644 --- a/src/Blazor.TanStack.VirtualCore/VirtualizeOptions.cs +++ b/src/Blazor.TanStack.VirtualCore/VirtualizeOptions.cs @@ -122,7 +122,7 @@ public sealed class VirtualizeOptions public VirtualizeRect? InitialRect { get; set; } /// - /// Measurements to seed the cache with, from a previous TakeSnapshotAsync. Restores exact + /// Measurements to seed the cache with, from a previous TakeSnapshot. Restores exact /// scroll geometry across a navigation without re-measuring every row. /// [JsonPropertyName("initialMeasurementsCache")] diff --git a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js index 7759a32..6237595 100644 --- a/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js +++ b/src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js @@ -607,7 +607,11 @@ class AdaptiveVirtualizeInstance { } getSnapshot() { - const range = this.virtualizer.range; + // The range field lags behind exactly as measurementsCache does - see refreshMeasurements - + // because calculateRange is the only thing that assigns it. It is memoized, and it brings the + // measurements up to date as well. Not getVirtualIndexes, which also writes the range into + // maybeNotify's dependencies, as if a render had already shown it. + const range = this.virtualizer.calculateRange(); return { scrollOffset: this.virtualizer.scrollOffset ?? 0, totalSize: this.virtualizer.getTotalSize(), diff --git a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs index 8b48bed..e83df05 100644 --- a/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs +++ b/tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs @@ -496,6 +496,51 @@ public async Task ScrollToIndex_RightAfterInvalidateItemSize_FirstWriteLandsOnTh _fixture.AssertNoJsErrors(); } + [Fact] + public async Task GetSnapshot_RightAfterAMeasurementChanges_ReportsTheRangeTheVirtualizerUses() + { + // Arrange + await _fixture.NavigateToStoryAsync(_story); + + // Act + var ranges = await _fixture.Page.EvaluateAsync( + "async () => {" + _mountScript + _measuredRowsScript + @" + const list = await mountMeasured(20, { useAnimationFrameWithResizeObserver: false }); + const range = () => { + const snapshot = list.instance.getSnapshot(); + return `${snapshot.startIndex}-${snapshot.endIndex}`; + }; + + // Rows of 80, 30, 60 and then 50px in the 200px viewport: rows 0 to 3 are visible. + const measured = range(); + + // Grown to 300px, row 0 fills the viewport alone. As in the TakeSnapshot test, this + // observer runs straight after virtual-core's has measured it. + const insideObserver = await new Promise(resolve => { + new ResizeObserver((_, observer) => { + observer.disconnect(); + resolve(range()); + }).observe(list.rows[0]); + list.rows[0].style.height = '300px'; + }); + await nextFrame(); + await nextFrame(); + + // Back at its 50px estimate, row 0 leaves room for rows 0 to 4. + list.instance.invalidateItemSize('row-0'); + const afterInvalidation = range(); + + list.instance.dispose(); + list.viewport.remove(); + return [measured, insideObserver, afterInvalidation]; + }"); + + // Assert + // Joined, so a failure shows every sample rather than only the first that differs. + Assert.Equal("0-3, 0-0, 0-4", string.Join(", ", ranges)); + _fixture.AssertNoJsErrors(); + } + private sealed class ItemSetSample { [JsonPropertyName("keys")] From 941c7a1132a39a0e5b08b5f10adec6361d04364e Mon Sep 17 00:00:00 2001 From: Kebechet Date: Mon, 2 Nov 2026 20:39:23 +0100 Subject: [PATCH 4/4] Release 3.19.8.2 --- .../Blazor.TanStack.VirtualCore.csproj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Blazor.TanStack.VirtualCore/Blazor.TanStack.VirtualCore.csproj b/src/Blazor.TanStack.VirtualCore/Blazor.TanStack.VirtualCore.csproj index b289bfb..af0a529 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.1 - 3.19.8.1: interop calls cut off by disposal no longer throw. + 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. + +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. + +3.19.8.1: interop calls cut off by disposal no longer throw. A ScrollToIndex, ScrollToOffset, ScrollToEnd, ScrollBy, ScrollToTop, CancelScroll, ResizeItem, Measure or InvalidateItemSize, or a GetTotalSize, GetVirtualIndexes, GetOffsetForIndex, GetVirtualItemForOffset, GetDistanceFromEnd, GetSnapshot or TakeSnapshot, that was still in flight when the component was disposed no longer throws once the JS side goes away - in a reloaded BlazorWebView it threw a JSException, and a caller that had not awaited it saw an unobserved task exception. A command now completes, a query returns its default, and the failure is logged through ILogger<AdaptiveVirtualize<TItem>>: at Debug for the known ways the JS side goes away, at Warning for anything else. The same holds for a render whose setOptions, setItems or measureRenderedRows was in flight, which surfaced as an unhandled render exception. A failure while the component is alive still throws. Callers no longer need a catch of their own around these calls for teardown.