Skip to content

Keep rows across Items changes and place them from JS - #5

Merged
Kebechet merged 3 commits into
mainfrom
perf/keep-rows-on-items-change
Nov 3, 2026
Merged

Kebechet merged 3 commits into
mainfrom
perf/keep-rows-on-items-change

Conversation

@Kebechet

@Kebechet Kebechet commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Release 3.19.8.3.

Keep rows whose placement went stale rendered, hidden

On an Items change a placement can carry a key that no longer matches the item at its index until JS sends fresh placements. Those rows were skipped, so every row component was disposed and built again one round trip later, even rows in both sets. They are now rendered under their own key with visibility:hidden until a matching placement arrives.

Measured in an exercise picker on a Samsung A53 WebView: per query change, 23% fewer render-batch bytes and 25% fewer components created and disposed.

Place rows from JS without waiting for .NET

Placements reached the DOM only through a .NET render, one interop round trip after the virtualizer computed them (~250 ms on the A53). Expanding a group showed both costs: the grown row painted over the rows below it for that long, then the rows below stayed hidden for ~300 ms after the inserted rows shifted every index.

  • Rows carry data-key.
  • syncRenderedRows moves every row whose key matches the key JS holds for its index: from the ResizeObserver callback (before the frame is painted), and when setItems arrives, which also shows rows .NET hid.
  • A MutationObserver puts back a position an older .NET render writes.
  • A ResizeObserver measurement now counts as measured, so the setItems after InvalidateItemSize keeps it instead of falling back to the estimate.
  • A row rendered from an item set JS has not received yet no longer has its size recorded under the key its index still belongs to.

On the A53 (expand traced 5 times, sampled after paint): 0 frames with overlapping rows, rows below hidden for 0-180 ms.

Tests

  • SlowInteropTests (E2E): holds OnVirtualItemsChanged back 600 ms and samples every painted frame - a row growing never overlaps the next one, and an expand shows the rows below within a frame. Both fail without syncRenderedRows.
  • InteropModuleTests: a ResizeObserver size survives the setItems after InvalidateItemSize; a row from a newer item set is measured only under its own key. Each fails without its fix.
  • Unit: stale rows render hidden with their own data-key; a row whose key stays keeps its component.

Unit 42/42, E2E 42/42 locally.

When the item set changes, a placement can carry a key that no longer
matches the item at its index until JS sends fresh placements. Those
rows used to be skipped, so every row component was disposed on each
item-set change and built again one round trip later - even rows that
are in both sets - with their media, images and JS state. They are now
rendered under their own key and hidden until a matching placement
arrives.

In an exercise picker on a Samsung A53 WebView this cut the render
batches of a query change by 23% in bytes and the components created
and disposed by 25%.
Placements reached the DOM only through a .NET render, one interop
round trip after the virtualizer computed them - ~250 ms in a Blazor
Hybrid app on a Samsung A53. Expanding a group showed both costs: the
grown row painted over the rows below it for that long, and once the
inserted rows shifted every index the rows below stayed hidden for
another ~300 ms.

Rows now carry data-key, and the JS side moves every row whose key
matches the key it holds for that index 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.
A MutationObserver puts back a position an older .NET render writes.
On the A53 the expand now paints no overlap at all, and the rows below
are hidden for 0-180 ms instead of ~300.

Two measurement fixes go with it. A size the ResizeObserver measured
now counts as measured, so the setItems after InvalidateItemSize keeps
it instead of falling back to the estimate. And a row rendered from an
item set JS has not received yet no longer has its size recorded under
the key its index still belongs to - the likely way an exercise row
once ended up with a section header's height.
Copilot AI balanced review requested due to automatic review settings November 3, 2026 00:33
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.91%. Comparing base (1c59dcf) to head (c3f5a8d).

Additional details and impacted files
@@            Coverage Diff             @@
##             main       #5      +/-   ##
==========================================
+ Coverage   77.77%   77.91%   +0.13%     
==========================================
  Files           7        7              
  Lines         333      335       +2     
  Branches       51       51              
==========================================
+ Hits          259      261       +2     
  Misses         64       64              
  Partials       10       10              

☔ 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 2ead693 into main Nov 3, 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.

🟡 Changes recommended

Rendered-row measurement bypasses key validation, allowing stale calls to persist incorrect row sizes, while deferred iOS scroll corrections can misplace rows.

2 open issues
What changed in this PR

This PR prepares release 3.19.8.3, reducing row component recreation and layout delays in the Blazor virtualizer, especially over slow WebView interop.

Changes:

  • Keep rows with stale placements rendered under their item keys but hidden.
  • Position matching rows directly from JavaScript and improve resize-measurement handling.
  • Add unit and browser regression tests for component retention, measurement correctness, and delayed interop.
File Description
tests/​Blazor.TanStack.VirtualCore.Tests/​AdaptiveVirtualizeTests.cs Tests hidden stale rows, item keys, and component retention.
tests/​Blazor.TanStack.VirtualCore.E2E/​SlowInteropTests.cs Adds delayed-interop tests for overlap and visibility.
tests/​Blazor.TanStack.VirtualCore.E2E/​InteropModuleTests.cs Tests measurement preservation and key isolation.
src/​Blazor.TanStack.VirtualCore/​wwwroot/​adaptive-virtualize-interop.js Synchronizes DOM placements and guards resize measurements.
src/​Blazor.TanStack.VirtualCore/​Blazor.TanStack.VirtualCore.csproj Updates the version and release notes.
src/​Blazor.TanStack.VirtualCore/​AdaptiveVirtualize.razor.cs Renders mismatched placements as hidden keyed rows.
src/​Blazor.TanStack.VirtualCore/​AdaptiveVirtualize.razor Adds item keys to row DOM attributes.

Review effort: Balanced

@@ -143,6 +173,25 @@ class AdaptiveVirtualizeInstance {
measureElement: (element, entry, instance) => {
if (entry) {
continue;
}

const start = toCssNumber(measurement.start);
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