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
26 changes: 23 additions & 3 deletions src/Blazor.TanStack.VirtualCore/AdaptiveVirtualize.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@
private string[] _itemKeys = [];
private double[] _itemSizes = [];

/// <summary>What the JS side already holds of the item set, so a change sends it only the rest.</summary>
private readonly ItemSlotTable _itemSlots = new();

private readonly List<PreparedRow> _preparedRows = [];
private IReadOnlyList<VirtualItem>? _preparedFromVirtualItems;
private IReadOnlyList<TItem>? _preparedFromItems;
Expand Down Expand Up @@ -230,7 +233,7 @@
return value.ToString("0.####", CultureInfo.InvariantCulture);
}

protected override async Task OnAfterRenderAsync(bool firstRender)

Check warning on line 236 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 236 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 236 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 All @@ -247,6 +250,7 @@
_lastItemsRef = Items;
_lastOptionsRef = Options;
BuildDescriptors();
_itemSlots.Reset(_itemKeys, _itemSizes);
_jsInstance = await _jsModule.InvokeAsync<IJSObjectReference>(
"create", _rootElement, _selfRef, _itemKeys, _itemSizes, EstimatedItemSize, Options);
}
Expand Down Expand Up @@ -310,7 +314,21 @@
// 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)
{
Expand Down Expand Up @@ -524,8 +542,10 @@

/// <summary>
/// Snapshots <see cref="Items"/> into the two arrays the JS side needs to answer the
/// virtualizer's <c>getItemKey</c> and <c>estimateSize</c> callbacks synchronously. Sent once
/// per <see cref="Items"/> reference change - never on scroll.
/// virtualizer's <c>getItemKey</c> and <c>estimateSize</c> callbacks synchronously. Rebuilt
/// once per <see cref="Items"/> reference change - never on scroll. They cross whole only when
/// the JS instance is created; a later change sends what <see cref="ItemSlotTable"/> finds the
/// JS side does not hold yet.
/// </summary>
/// <remarks>
/// Two parallel arrays rather than one array of objects, because this crosses the interop
Expand All @@ -550,14 +570,14 @@
}

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

Check warning on line 573 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 573 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 573 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 580 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 580 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.1</Version>
<PackageReleaseNotes>3.19.8.1: interop calls cut off by disposal no longer throw.
<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.

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&lt;AdaptiveVirtualize&lt;TItem&gt;&gt;: 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.

Expand Down
156 changes: 156 additions & 0 deletions src/Blazor.TanStack.VirtualCore/ItemSlotTable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
namespace Kebechet.Blazor.TanStack.VirtualCore;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// The JS side needs the whole set - it answers virtual-core's <c>getItemKey</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// In <see cref="ItemSetUpdate.Order"/> a non-negative entry is a slot id and a negative entry
/// <c>-n</c> continues the previous slot with the next <c>n</c>, so a stretch of items in the order
/// they were first sent - typically the whole unfiltered list - costs two numbers.
/// </para>
/// </remarks>
internal sealed class ItemSlotTable
{
/// <summary>
/// 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.
/// </summary>
private const int _maxSlotsPerItem = 2;

private readonly Dictionary<(string Key, double Size), int> _slotIdsByEntry = [];
private readonly List<string> _newKeys = [];
private readonly List<double> _newSizes = [];
private List<int> _order = [];
private List<int> _sentOrder = [];
private int _slotCount;
private int _largestItemCount;
private bool _isRebuildRequired;

/// <summary>
/// Starts the table over from arrays the JS side takes whole, as it does those the instance is
/// created from: slot <c>i</c> is item <c>i</c>.
/// </summary>
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));
}
}

/// <summary>
/// Makes the next <see cref="Encode"/> 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.
/// </summary>
public void Invalidate()
{
_isRebuildRequired = true;
}

/// <summary>The arguments of the interop module's <c>setItems</c> that turn its item set into this one.</summary>
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;
}
}
}
}

/// <summary>The arguments of the interop module's <c>setItems</c>; see <see cref="ItemSlotTable"/>.</summary>
/// <param name="Order">The set as slot ids, or null when it is the set the JS side already holds.</param>
/// <param name="NewKeys">Keys of the slots appended before <paramref name="Order"/> is read.</param>
/// <param name="NewSizes">Size estimates of those slots.</param>
/// <param name="IsTableReset">Whether the JS side drops its table before appending.</param>
internal readonly record struct ItemSetUpdate(
IReadOnlyList<int>? Order,
IReadOnlyList<string> NewKeys,
IReadOnlyList<double> NewSizes,
bool IsTableReset);
2 changes: 1 addition & 1 deletion src/Blazor.TanStack.VirtualCore/VirtualizeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
public VirtualizeRect? InitialRect { get; set; }

/// <summary>
/// Measurements to seed the cache with, from a previous <c>TakeSnapshotAsync</c>. Restores exact
/// Measurements to seed the cache with, from a previous <c>TakeSnapshot</c>. Restores exact
/// scroll geometry across a navigation without re-measuring every row.
/// </summary>
[JsonPropertyName("initialMeasurementsCache")]
Expand Down Expand Up @@ -220,10 +220,10 @@
public sealed class VirtualizeRect
{
[JsonPropertyName("width")]
public double Width { get; set; }

Check warning on line 223 in src/Blazor.TanStack.VirtualCore/VirtualizeOptions.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'VirtualizeRect.Width'

[JsonPropertyName("height")]
public double Height { get; set; }

Check warning on line 226 in src/Blazor.TanStack.VirtualCore/VirtualizeOptions.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'VirtualizeRect.Height'
}

/// <summary>Which edge the list stays anchored to when content shifts.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -462,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) {
Expand Down Expand Up @@ -553,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;
Expand All @@ -568,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(),
Expand All @@ -588,10 +631,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);
Expand Down
Loading
Loading