Skip to content

Send setItems only what JS lacks, and report current sizes in snapshots (3.19.8.2) - #4

Merged
Kebechet merged 4 commits into
mainfrom
perf/setitems-slot-table
Nov 2, 2026
Merged

Kebechet merged 4 commits into
mainfrom
perf/setitems-slot-table

Conversation

@Kebechet

@Kebechet Kebechet commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Summary

Profiling the SatisFIT exercise picker on Android (Blazor Hybrid) showed AdaptiveVirtualize resending its whole item set across the interop bridge on every Items change: about 94K characters for a 4,010-row list, per keystroke of a search, as JSON copied through the WebView bridge. While fixing that, TakeSnapshot, GetOffsetForIndex, ScrollToIndex and GetSnapshot turned out to read virtual-core state that lags behind what the virtualizer actually uses.

Changes

  1. Send setItems only the keys the JS side does not hold yet.

    • Both sides keep a table of the (key, size estimate) pairs already sent (ItemSlotTable in C#, its mirror in the interop module). 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 an item change exactly as before.
    • The table is rebuilt when it outgrows twice the largest set since the last rebuild (a list whose keys keep changing cannot grow it without bound), and after a failed call.
    • The public API is unchanged.
    Step (4,010 rows) Before After
    b / be / ben / benc / bench 37,911 / 9,773 / 3,145 / 1,122 / 1,013 7,584 / 2,004 / 637 / 234 / 211
    back to the full list 94,155 23
    new list, same content 94,155 18
    all setItems calls 293,225 21,170
  2. Read current measurements in TakeSnapshot, GetOffsetForIndex and ScrollToIndex. virtual-core only refreshes measurementsCache inside getMeasurements(). Inside a ResizeObserver delivery (the notification is held one task) and after InvalidateItemSize (which raises none) it still held old sizes, so TakeSnapshot returned them, GetOffsetForIndex computed from them, and ScrollToIndex landed its first write on the old offset (520 instead of 490) before jumping a frame later. All three refresh through the memoized getMeasurements() first.

  3. Report the current range in GetSnapshot. StartIndex/EndIndex came from virtual-core's range field, which lags the same way. It now asks calculateRange().

  4. Release 3.19.8.2.

Testing

  • Unit tests (bUnit): 39/39 pass. 9 are new (ItemSetInteropTests, recording through a real JSRuntime subclass): a content-equal list sends no keys, a reordered subset sends none JS already holds, going back to an earlier list sends none, only never-seen keys are sent, a changed estimate for a known key is sent alone, duplicate keys with different estimates both reach JS, a list whose keys keep changing keeps the table bounded, and a failed setItems resends the whole set. Six of them fail on the old code on their payload assertion.
  • Browser tests (Playwright): 38/38 pass. 6 are new or changed: decoding and measurement preservation across a reordered subset and back, collapsing and re-expanding a group, and one test each for the stale TakeSnapshot, GetOffsetForIndex, ScrollToIndex and GetSnapshot windows. Each of the last four fails on the old code with the stale values.
  • Release build: no errors on net6.0 through net10.0; the package-content check passes.

After merge

  1. Run the Publish to NuGet workflow to release 3.19.8.2.

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.
…llToIndex

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.
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.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 19:39
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.77%. Comparing base (8e8809c) to head (941c7a1).

Additional details and impacted files
@@            Coverage Diff             @@
##             main       #4      +/-   ##
==========================================
+ Coverage   71.69%   77.77%   +6.07%     
==========================================
  Files           6        7       +1     
  Lines         265      333      +68     
  Branches       41       51      +10     
==========================================
+ Hits          190      259      +69     
  Misses         64       64              
+ Partials       11       10       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Kebechet
Kebechet merged commit 1c59dcf into main Nov 2, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues block approval of the interop and measurement-refresh changes.

No open issues 🎉

What changed in this PR

This PR reduces AdaptiveVirtualize item-update traffic across the Blazor JavaScript bridge and ensures snapshot and scrolling APIs use current measurements, without changing the public API.

Changes:

  • Adds mirrored slot tables so item updates send compact ordering data and only previously unsent key/estimate pairs.
  • Refreshes measurements and visible ranges before snapshot, offset, and index-scrolling operations.
  • Adds regression coverage and updates the package to version 3.19.8.2.
File Description
tests/​Blazor.TanStack.VirtualCore.Tests/​ItemSetInteropTests.cs Tests serialized payloads, slot-table bounds, and recovery after failed calls.
tests/​Blazor.TanStack.VirtualCore.E2E/​VirtualizationTests.cs Tests row rendering after collapsing and re-expanding a group.
tests/​Blazor.TanStack.VirtualCore.E2E/​InteropModuleTests.cs Tests slot decoding, measurement preservation, and current snapshot and scroll values.
src/​Blazor.TanStack.VirtualCore/​wwwroot/​adaptive-virtualize-interop.js Decodes slot updates and refreshes measurements and ranges before queries and scrolling.
src/​Blazor.TanStack.VirtualCore/​VirtualizeOptions.cs Corrects the snapshot method name in documentation.
src/​Blazor.TanStack.VirtualCore/​ItemSlotTable.cs Implements compact slot encoding, bounded table growth, and reset support.
src/​Blazor.TanStack.VirtualCore/​Blazor.TanStack.VirtualCore.csproj Bumps the package version and adds release notes.
src/​Blazor.TanStack.VirtualCore/​AdaptiveVirtualize.razor.cs Integrates slot encoding into item updates and invalidates the table after failures.

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants