Skip to content

Send the item set ahead of the render that shows it - #6

Merged
Kebechet merged 8 commits into
mainfrom
fix/send-items-before-render
Nov 3, 2026
Merged

Kebechet merged 8 commits into
mainfrom
fix/send-items-before-render

Conversation

@Kebechet

@Kebechet Kebechet commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

Release 3.19.8.4.

Send the options and the item set ahead of the render that shows them

After an Items change the rows whose placement went stale render hidden until the JS side holds the new set (3.19.8.3). setItems went out from OnAfterRenderAsync, one round trip after that render, and a WebView paints in between: on a Samsung A53 expanding a group in an exercise picker painted every row below it hidden in 3 of 5 expands, for 48-224 ms - a dark band flashing under the group.

OnParametersSet now sends setOptions and setItems, in that order, before the render. By the time the render's rows reach the DOM the JS side holds their keys, and the MutationObserver that sees them places and shows them before the frame is painted. Options go first because setItems reacts to the count and edge keys through FollowOnAppend and AnchorTo. OnAfterRenderAsync awaits the sends, and still sends what changed while the instance was being created.

Measure rows as they are rendered

  • InvalidateItemSize reads a rendered row's size off the row instead of dropping it, so a set that arrives before the row's content changes no longer moves the rows below to the estimate; virtual-core's ResizeObserver takes the new size when the content changes. A row that is not rendered, or any row under UseCachedMeasurements, still falls back to the estimate.
  • A size for a row of the active set goes through virtual-core's resizeItem, which keeps its scroll correction for rows above the viewport.
  • A row still drawn from the replaced set that resizes before the render lands keeps that size under its own key (written directly, with pendingMin reset so the recompute reaches it). virtual-core drops a ResizeObserver entry whose index is past the new count before it reaches us, so setItems notes the rows still drawn from the replaced set and the measure pass after the render reads their sizes off the rows.
  • Keys are compared to undefined in the measure pass, so an empty-string key is handled like any other.

Measured

Release build with this package from a local feed, the picker's first group expanded and every painted frame sampled, 10 expands per device:

Device Frames with hidden rows Frames with overlapping rows Painted layouts per expand
Samsung A53 (before: 3.19.8.3) 3 of 5 expands, 48-134 ms 0 4
Samsung A53 0 0 3
Pixel 6 0 0 3
iPhone 11 0 0 3

The three layouts are: collapsed, the grown group with the row below already under it, the inserted rows in place. Some Android samples showed the grown group over the next row; a ResizeObserver on the group showed each of them read a layout no frame had painted yet (the sampler task ran after a render batch and before the next frame), and none was ever painted.

Upstream

The README's stopgap table names each with the test that fails once a bump makes the stopgap wrong.

Tests

Each new test failed without its change:

  • Unit: setItems reaches JS before the render that shows the new items; setOptions reaches it before setItems.
  • InteropModuleTests: an invalidated row on screen keeps its rendered size; one that already changed takes its current size; under cached measurements it falls back to the estimate; a row above the viewport that already grew keeps the content in place; a row of the replaced set keeps its new size once rendered again, also when it resized past a shrinking set's count, also with an empty key, and also after a later row resized first in the same ResizeObserver batch.
  • SlowInteropTests now allows no painted frame with hidden rows (it allowed one). The in-browser demo answers too fast to fail it without the change; the unit test covers that.

Unit 44/44, E2E 50/50 locally; CI green.

InvalidateItemSize used to evict the key, so the rows below fell back
to the estimate until something measured the row again. Once the item
set travels ahead of the render that changes the row, that would move
every row below to the estimate around a row that had not changed yet.
A rendered row is now measured as it is; virtual-core's ResizeObserver
takes its new size when its content changes. A row that is not
rendered still falls back to the estimate.
setItems went out from OnAfterRenderAsync, one round trip after the
render that draws the rows a new item set moves - hidden, because
their placements are stale until JS holds the set. A WebView paints
in between: on a Samsung A53 every row below an expanding group was
painted hidden for up to 220 ms. OnParametersSet now sends the set
before the render, so the observer that sees the render's rows places
and shows them before the frame is painted. OnAfterRenderAsync awaits
the send, and still sends a set that changed while the instance was
being created.
Copilot AI balanced review requested due to automatic review settings November 3, 2026 10:30
@codecov-commenter

codecov-commenter commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.67%. Comparing base (2ead693) to head (27b1928).

Additional details and impacted files
@@            Coverage Diff             @@
##             main       #6      +/-   ##
==========================================
+ Coverage   77.91%   78.67%   +0.76%     
==========================================
  Files           7        7              
  Lines         335      347      +12     
  Branches       51       52       +1     
==========================================
+ Hits          261      273      +12     
  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.

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

Item updates can use outdated options, while measurement invalidation and deferred resize handling can retain stale row sizes, causing incorrect append-follow behavior, scroll offsets, and row overlap.

3 open issues
What changed in this PR

This release updates Blazor-to-JavaScript synchronization to prevent hidden-row flashes during item-set changes and preserve rendered row sizes during invalidation.

Changes:

  • Sends changed item sets before rendering, then awaits outstanding sends after rendering.
  • Measures rendered rows during size invalidation while retaining the estimate fallback for unrendered rows.
  • Adds regression coverage, tightens painted-frame assertions, and updates the package to 3.19.8.4.
File Description
tests/​Blazor.TanStack.VirtualCore.Tests/​AdaptiveVirtualizeTests.cs Tests pre-render item dispatch and updates disposal call-order expectations.
tests/​Blazor.TanStack.VirtualCore.E2E/​SlowInteropTests.cs Requires zero painted frames with hidden rows.
tests/​Blazor.TanStack.VirtualCore.E2E/​InteropModuleTests.cs Tests rendered-row size preservation through invalidation and growth.
src/​Blazor.TanStack.VirtualCore/​wwwroot/​adaptive-virtualize-interop.js Adds rendered-row handling during invalidation and clarifies timing comments.
src/​Blazor.TanStack.VirtualCore/​Blazor.TanStack.VirtualCore.csproj Updates the package version and release notes.
src/​Blazor.TanStack.VirtualCore/​AdaptiveVirtualize.razor.cs Dispatches item sets before rendering and awaits queued sends afterward.

Review effort: Balanced

setItems reacts to the item count and edge keys through options such
as FollowOnAppend and AnchorTo. Sent ahead of the render while
setOptions still went out after it, a change of both in one render
applied the append behaviour of the options being replaced. Both now
go out from OnParametersSet, options first, as they did when both
went out after the render.
virtual-core answers a measurement without a ResizeObserver entry
from its size cache. invalidateItemSize measured through it and so
kept the size the row had before; it now reads the row itself, and
still drops the size under useCachedMeasurements, where nothing reads
rows. A row of the item set that setItems just replaced dropped a
resize it got before its render landed, and the measure pass after
the render read the old size back; that size is now kept under the
row's own key. Release notes updated for both.

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

Active measurement-cache issues can leave resized or moved rows with stale sizes and offsets, causing overlaps and preventing scroll-anchor correction.

3 open issues
3 resolved since last review

Review effort: Balanced

A size read off a row of this instance's item set now goes through
virtual-core's resizeItem, which corrects the scroll offset when a row
above the viewport changes size; written straight into the cache, the
content jumped by the difference. A size kept for a row still drawn
from the replaced set is written directly, and resets pendingMin so
the recompute reaches its index. virtual-core drops a ResizeObserver
entry whose index is past the item count, so setItems now notes the
rows still drawn from the set it replaced, and the measure pass after
the render reads their sizes off the rows.

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

A truthiness check skips forced remeasurement for valid empty-string item keys, allowing stale cached heights to leave subsequent rows misplaced after a shrink.

1 open issue
3 resolved since last review

Review effort: Balanced

measureRenderedRows tested keys for truthiness, so a row keyed "" was
never recorded as measured there and skipped the re-read of rows from
a replaced item set. The ResizeObserver does record "" as measured, so
such a row that resized past the new count before its render landed
kept its old size.
The right-to-left scroll write and the stale getOffsetForIndex are
upstream bugs with open fixes (TanStack/virtual PRs 1291 and 1363);
the table names them and the tests that fail once a bump makes each
stopgap wrong. InvalidateItemSize now measures a rendered item, and
the internals note lists pendingMin and the proposed public API
(TanStack/virtual discussion 1362).

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 were identified in the item-set rendering and row-measurement changes.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@Kebechet
Kebechet merged commit 12fe83f into main Nov 3, 2026
3 checks passed
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