Skip to content

Teardown fixes, virtual-core 3.19.8, IsRtl and touch-fling fixes (3.19.8) - #1

Merged
Kebechet merged 16 commits into
mainfrom
fix/sfa-3058-dispose-jsexception
Oct 28, 2026
Merged

Kebechet merged 16 commits into
mainfrom
fix/sfa-3058-dispose-jsexception

Conversation

@Kebechet

@Kebechet Kebechet commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

Started as the fix for DisposeAsync throwing JSException: JS object instance with ID n does not exist (has it been disposed?). It now also covers the disposal races found around it, updates the bundled @tanstack/virtual-core from 3.17.7 to 3.19.8, and fixes what testing on real devices turned up. Released as 3.19.8.

Commits

Teardown

  1. Treat a JS object already gone from the page as disposed. When a BlazorWebView's page reloads, the reloaded page's attach request disposes the previous page's renderer (WebViewManager.AttachToPageAsync), so the old renderer's dispose call reaches a page that never created the object.
  2. Stop a render's interop calls once disposal has started. Otherwise the render went on to fail on the render path. The tests cover each of the three awaits.
  3. Release JS objects that arrive after the component is disposed. This covers an import or create still in flight at disposal.
  4. Finish disposal when a JS interop call fails or times out. This covers Blazor Server's interop timeout, and a create that fails after disposal.
  5. Send parameter changes made while the JS instance is being created. Before this, they were marked as sent and never reached the instance.

virtual-core 3.19.8

  1. Update @tanstack/virtual-core from 3.17.7 to 3.19.8.
    • Re-vendored the same way as before; the method is shown to be reproducible.
    • The JS guard against notifications after dispose was dropped, because upstream's teardown now covers it. A browser test pins that guarantee.
  2. Count directional overscan in rows when there are lanes. This matches the change upstream made in 3.19.0.
  3. Let virtual-core re-issue scroll writes the browser clamped. A ResizeObserver on the container now calls _willUpdate() once the container has grown, so a chat-style list stays pinned to the bottom.
  4. Add CancelScroll, the one Virtualizer member that is new since 3.17.7.

Found by testing on real devices

  1. Keep .NET renders out of ResizeObserver callbacks. In Blazor WebAssembly, flinging a list raised ResizeObserver loop completed with undelivered notifications as a window error on nearly every measurement pass, far more often under 3.19. The fix brings that to 0, with no extra frames in which rows overlap or leave gaps. The browser suite now also catches this kind of error, which Playwright did not report before.
  2. Stop a touch fling before a scroll command writes. On Android, a jump issued by code mid-fling used to end about 600 rows past its target. This is not a regression: 3.17.7 behaves the same. virtual-core stops a fling before a command only on iOS 27, so this is a stopgap until fix(virtual-core): stop a touch fling for a command issued during it on Android too TanStack/virtual#1347 ships.
  3. Make IsRtl work for horizontal lists. It now places items from the right edge and negates scroll writes. The demo's container is now direction: rtl.
  4. Take the timing out of the directional overscan tests.
  5. Log the interop failures that teardown swallows. These used to sit in empty catch blocks. They now go through ILogger<AdaptiveVirtualize<TItem>>: at Debug for the known ways the JS side goes away, so they stay out of error trackers, and at Warning for anything else. A failing virtual-core cleanup in JS now raises a console.warn.
  6. Stop the fling on iOS too, and name its upstream fix. The stop no longer copies virtual-core's private iOS user-agent check. It now runs after every touch fling; on iOS, where it isn't needed, it costs two frames. When a classic scrollbar takes layout space (a touchscreen laptop), the overflow is put back before anything paints, so the list doesn't reflow. The README lists the stop as a stopgap for fix(virtual-core): stop a touch fling for a command issued during it on Android too TanStack/virtual#1347 (issue virtual-core: on Android Chrome, a scrollToIndex issued during a touch fling overshoots its target TanStack/virtual#1346), to be removed with the virtual-core release that contains it.
  7. Release 3.19.8.

Testing

  • Unit tests (bUnit): 20/20 pass. Each fix's test was confirmed to fail without the fix.
  • Browser tests (Playwright): 32/32 pass on two full runs. The directional tests also passed 45 of 45 times under 4x CPU throttling with every core loaded.
  • Release build: clean on net6.0 through net10.0. The package was checked to carry its static web assets, and its vendored bundle matches the SHA-256 in the README.
  • Real devices:
    • Devices tested: a Pixel 6 (Android 16, Chrome 156) and an iPhone 11 (iOS 26.2, Safari).
    • Every story was flung with real touch and checked for errors, overlaps, gaps and blank areas.
    • Follow on append, the mid-fling jump and the right-to-left rail were checked on both phones.
    • The JS module checks (teardown, lanes, end-anchor re-issue, cancel) were run in each phone's browser.
    • All are clean on both phones.
    • After the iOS check was removed, the mid-fling jump (issued 150ms into a fling by a timer) was run again: it lands 3/3 on the Pixel and 3/3 on the iPhone. The list holds still through the two hidden frames. Plain virtual-core 3.19.8 misses 3/3 on the Pixel.

After merge

Run the Publish to NuGet workflow to release 3.19.8.

DisposeAsync caught JSDisconnectedException and ObjectDisposedException but
not JSException, which is what Blazor throws when the call targets a JS
object ID the page does not hold: "JS object instance with ID n does not
exist (has it been disposed?)". It surfaced as an unobserved task exception
during Renderer.Dispose.

A BlazorWebView triggers this whenever its page reloads. The reloaded page's
attach request is what disposes the previous page's renderer
(WebViewManager.AttachToPageAsync), and the IPC channel is shared, so the
old renderer's dispose call reaches a page that never created the object.

Only the instance's dispose call can fail this way. Releasing a reference
(IJSObjectReference.DisposeAsync) deletes the ID from the registry and never
throws for an unknown one, so the module path is left as it was.

Pinned by a bUnit test that fails the instance's dispose call with that
exception.
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

OnAfterRenderAsync awaits up to three interop calls in a row (setOptions,
setItems, measureRenderedRows) and never checked whether the component had
been disposed in between. When it was disposed while one of those calls was
in flight, the render carried on: it sent work to an instance whose dispose
call was already on its way, and once DisposeAsync had released that
instance the next call threw ObjectDisposedException on the render path. In
a BlazorWebView that is the error UI plus an unobserved task exception; in
Blazor Server it ends the circuit. A parent that passes Options inline makes
it likelier, because every parent render is then an options change.

DisposeAsync now raises a flag first, and the render returns after each
await once it is set, skipping OnLayoutMeasured as well.

Pinned by a test that holds setOptions in flight across disposal and asserts
the render sends nothing further.
Disposal only released what already existed. When it ran while the first
render was still awaiting `import` or `create`, it found nothing and
returned, and the late result was then assigned to a field nobody would read
again:

- A late `create` left a live JS virtualizer behind, still observing scroll
  and resize (on the window, in window-scroller mode) for the life of the
  page. Disposal had also released the DotNetObjectReference that instance
  was constructed with, so every notification it sent after that - a
  scroll, a resize - failed in the browser.
- A late `import` went on to call `create` after disposal and never
  released the module.

The first render now checks for disposal after each await. A late module
skips `create`, and whatever arrived is released by that render. While
initialization is in flight, disposal also leaves the DotNetObjectReference
alone: the instance on its way holds it and can call it at once, so the
first render releases it after disposing that instance.

Release moves into ReleaseJsAsync, which can now run twice, so it takes each
reference out of its field before awaiting. That also stops the imperative
API from reaching an object while it is being released.

Pinned by two tests that hold `import` and `create` in flight across
disposal, using a small hand-rolled fake because bUnit's module stand-ins
always answer at once.
Blazor Server cancels an interop call that outlives
CircuitOptions.JSInteropDefaultCallTimeout, which is what a browser that has
stopped answering looks like from the server. Disposal caught neither that
TaskCanceledException nor anything like it, so a timed-out dispose call
escaped DisposeAsync and skipped the module release and the
DotNetObjectReference release behind it. Both release steps now treat a
cancellation like the other ways the JS side can already be gone.

The same gap existed for a `create` still in flight when the component is
disposed: if it then timed out or failed, the exception escaped the first
render before its release ran, so the runtime kept tracking the
DotNetObjectReference that call had carried and the disposed component
stayed pinned. A failure of `import` or `create` once disposal has started is
now swallowed - the component is gone, and letting it escape would only
surface as an unhandled render exception.

Pinned by a test in which both the instance's dispose call and the module
release time out (removing either catch fails it), and by one in which a
held `create` times out or fails after disposal. The timeout test awaits
DisposeAsync directly because Shouldly's NotThrowAsync reports a canceled task
as having not thrown.
The first render recorded Items and Options as already sent only once
`create` had returned, reading whatever they were by then. A change that
landed while `create` was in flight rendered with no instance to send it to
- OnAfterRenderAsync returns early while _jsInstance is null - and was then
marked as sent anyway. The instance kept virtualizing the item set and
options it was created from until some later change happened to come along.

Both are now recorded before the await, as what the instance is created
from, so the render queued after `create` sees the difference and sends
setOptions / setItems.

Pinned by a test that changes both while `create` is held in flight.
Re-vendored the same way as before - jsDelivr's self-contained `+esm`
rollup of the npm release. Re-fetching 3.17.7 that way reproduces the
previous file byte for byte, so the method is unchanged. The options
surface is identical (36 members), and every internal the interop module
relies on - `_willUpdate`, `_didMount`, `itemSizeCache`,
`itemSizeCacheVersion`, `measurementsCache`, `range` - is still there.

Among the upstream fixes since 3.17.7 is the teardown gap this package
would otherwise have to work around: 3.17.8 cancels the pending isScrolling
reset when a scroll observer is torn down, and 3.19.4 cancels animation
frames still queued under useAnimationFrameWithResizeObserver. Before them,
onChange could fire after dispose and notify a DotNetObjectReference that
.NET releases right after the dispose call. Every other deferred callback
in 3.19.8 - the item ResizeObserver's frames, the momentum-stop frames, the
iOS touch-tail timer - is cancelled or guarded on teardown too, so nothing
is needed on this side. A browser test pins that guarantee: it disposes
between virtual-core queuing a resize frame and that frame running, and
asserts no notification follows. Against 3.17.7 one gets through.

Behaviour changes that reach consumers:
- 3.19.0: with Lanes > 1, Overscan counts whole rows rather than items.
- 3.18.0: ScrollToIndex(last, End) no longer overshoots by paddingEnd;
  ScrollToEnd still reaches the very bottom.
- Many iOS WebKit scroll-correction fixes (flings, touchcancel, corrections
  held during touch and rubber-banding instead of dropped).
Since virtual-core 3.19.0 overscan counts whole rows when lanes > 1, so
every lane renders and measures the same number of extra items. Only
defaultRangeExtractor applies that, though. A custom rangeExtractor gets
row overscan only if it delegates there, and the directional one does not,
so OverscanLeading / OverscanTrailing on a masonry grid still counted
single items: the edge rows were partly rendered and some lanes were
buffered less than others.

The directional extractor now multiplies both counts by the lane count, as
defaultRangeExtractor does. The extractor's memo key gained `lanes` in
3.19.0 too, so the comments quoting that key say so now.

Pinned by a browser test that drives the module with three lanes and
asserts one row behind and four rows ahead. Without the change it renders
one item behind.
When an item grows while the list is anchored to the end, virtual-core
compensates by writing scrollTop straight away - before the container has
grown to fit. The browser clamps that write to the old maximum. Since 3.17.9
virtual-core records such a write and re-issues it from _willUpdate once the
container has grown, but that relies on _willUpdate being called after the
consumer commits the new size, which every upstream framework adapter does on
each render. This wrapper only called it on item-set and options changes, so
the re-issue never ran: a chat-style list ended exactly the growth short of
the bottom, with the grown item's tail hidden.

The container here is sized by a Blazor render one interop round-trip after
onChange, so the instance now watches its root element with a ResizeObserver
and calls _willUpdate whenever it resizes - the point where an upstream
adapter's commit would land. The observer is disconnected on dispose.

Pinned by a browser test of upstream's own scenario (5 x 50px items, 80px
paddingEnd, the last item growing by 70px) with a .NET stand-in that sizes
the container 30ms after each notification. Without the observer the list
stops 70px short.
After ScrollToIndex, ScrollToOffset, ScrollToEnd or ScrollBy, virtual-core
keeps re-aiming at the target for a while as items are measured. 3.18.0 added
cancelScroll() to stop that once the user scrolls themselves, so the
viewport stays where they take it instead of being pulled back. It is the one
Virtualizer member 3.19.8 adds over 3.17.7, and the README's coverage claim
covers every member that means something to a .NET caller, so it is surfaced
as CancelScroll.

Pinned by a browser test of upstream's own scenario - a row above the target
growing right after the command - which ends at 2650 without the cancel and
stays at 2500 with it, plus a unit test that the call reaches the instance.
It is not a story test like the rest of the imperative API: its effect only
shows when the cancel lands in the same task as the scroll it cancels.
Found flinging the demo on a Pixel 6: a variable-height list raised
"ResizeObserver loop completed with undelivered notifications" as a window
error on nearly every measurement pass - 29 times in ten flings. The layout
itself was right; the errors are what an app's error tracker records.

Blazor WebAssembly runs the .NET handler of invokeMethodAsync, and the render
it queues, synchronously. A notification sent from inside a ResizeObserver
callback therefore rewrites the DOM in the middle of the browser's
resize-observation step, and rows it adds or resizes can only be observed a
frame later, which the browser reports as that error. virtual-core 3.19
measures a whole batch before notifying, which made it far more frequent: on
a desktop probe across four stories, 9/6/2/18 errors against 2/0/0/0 under
3.17.7, and the container observer added in the previous commits was not a
factor. Honouring onChange's `sync` flag, as upstream's adapters do, barely
helps, because those notifications mostly carry a scroll adjustment.

Notifications raised from inside an observer callback - the item observer,
seen through the measureElement this module supplies, the scroll-element
observer, through its wrapped observeElementRect, and the container
observer - now go out one task later, coalesced into one. Everything else,
scroll-driven notifications included, is still sent synchronously. On the
same probe that removed every error without adding a single frame in which
rows overlap or leave gaps (25 of 130 before and after). dispose cancels a
notification still queued.

Playwright reports neither this error nor any other error event that carries
no exception, so the fixture now forwards those to the console, where
AssertNoJsErrors sees them; that alone failed the window-scroller test.
Pinned by a test that scrolls the variable-height list quickly, and the
teardown test now runs with and without useAnimationFrameWithResizeObserver
- without the cancel in dispose both fail.
Android Chrome carries a touch fling on after a programmatic scroll write. A
ScrollToIndex, ScrollToOffset, ScrollToEnd or ScrollBy issued while the
list was still flinging therefore landed wherever the momentum took it: on a
Pixel 6, a jump to row 12345 ended around row 12970, three runs out of
three. 3.17.7 did the same, so this is not new with the upgrade.
virtual-core handles it only on iOS 27 and later, where WebKit started
letting a fling override the write; earlier iOS stops the fling on the
write itself (confirmed on an iPhone 11 on iOS 26.2).

A person tapping a jump button never hits this on Android: Chrome uses a
touch during a fling to stop it and swallows the tap, so the next tap
finds the list at rest. It is a command issued by code mid-fling - a new
message arriving, a timer - that overshot.

Those commands now first hide the scroll axis's overflow for two frames,
which stops the momentum, then restore the element's own value and write -
the technique virtual-core uses on iOS 27. Its own _stopMomentum is not
reused: it is private, and it drops the callback of a command superseded
mid-stop, which would leave that command's .NET caller awaiting forever.
The stop applies only after touch input, with no finger down, while the
virtualizer reports scrolling and not on iOS. After mouse or wheel input the
write goes out at once, because on a desktop hiding the overflow would also
hide a classic scrollbar and reflow the list. Commands issued during the
same stop share it. Input is tracked from the window, so the tap on the
button itself counts, and the listeners are removed on dispose.

With the change, the same Pixel run lands on row 12345 three times out of
three. Pinned by a browser test that simulates a touch that has lifted with
the list still moving, and the same with the mouse wheel: the first must
hide overflow-y, write two frames later and restore "auto"; the second must
write at once.
IsRtl was passed through to virtual-core and nothing else, and on its own
that does not produce a working right-to-left rail. Three gaps, all visible
on a Pixel 6 and in the browser suite:

- Placement. The component placed every horizontal item at left:0 plus
  translateX(start). virtual-core measures offsets from where reading
  starts, which under isRtl is the right edge, so item 0 sat at the far end
  of the rail. Items are now placed from right:0 with translateX(-start).
- Scroll writes. virtual-core negates scrollLeft when it reads the offset
  under isRtl but writes the offset back unchanged. In a right-to-left
  container scrollLeft runs from 0 down to negative values, so every
  positive write clamped to the start and ScrollToIndex went nowhere. The
  module now negates the write to match the read.
- The demo. The Right to left story ran its container left to right, so
  virtual-core read every offset as negative and the window never moved off
  the first items. It now uses direction: rtl, and IsRtl's docs say the
  container has to.

The old browser test only checked that an RTL list rendered something. It
is replaced by two that pin the behaviour the story promises: item 0
starts against the right edge and the window follows a 4000px scroll left,
and ScrollToIndex brings item 500 on screen. A unit test pins the
placement markup. Removing the placement fails both browser tests;
removing the write negation fails the ScrollToIndex one.
One run of the browser suite timed out in
DirectionalOverscan_AfterReversing_FlipsWhichSideIsBuffered: scrolling the
list never moved its rendered window within 15s. It could not be reproduced
afterwards - not across 20 repeats, 6x CPU throttling, or with
virtual-core's own scroll writes deliberately delayed by 0 to 400ms - so
its exact cause is not known. That run did share the machine with a second
browser suite and builds. What the test did leave to timing is fixed here.

- The three directional tests clicked "Scroll to 2000" and started
  scrolling by hand at once, racing the component's own scroll command.
  They now wait until the story's state shows the jump has landed.
- ScrollViewportAsync waited by polling on every animation frame,
  Playwright's default, which a busy machine can starve. It now polls on a
  50ms timer, and a timeout names the scroll position before and after and
  the rendered rows, so a recurrence explains itself.
- A stress run - the three tests 45 times under 4x CPU throttling, with
  every core loaded - then failed 4 times on a different race: the forward
  test asserted ScrollDirection, read through a .NET round-trip, which can
  land after virtual-core's 150ms isScrolling reset reports no direction.
  The direction checks now accept that. The buffer counts still carry the
  claim, and with no direction they would revert to the forward bias, so
  the reversal is still pinned.

The same stress run now passes 45 out of 45.
@Kebechet
Kebechet force-pushed the fix/sfa-3058-dispose-jsexception branch from 505c832 to 90fdd7f Compare October 28, 2026 13:33
@Kebechet Kebechet changed the title Teardown fixes: dispose-time JSException and disposal races (3.17.7.3) Teardown fixes, virtual-core 3.19.8, IsRtl and touch-fling fixes (3.19.8) Oct 28, 2026
Teardown deliberately does not let several interop failures escape -
JSDisconnectedException, ObjectDisposedException, JSException and
TaskCanceledException while releasing the JS instance and module, and any
failure of import or create once the component is disposed. Each was
swallowed in an empty catch, so when one happened there was no trace of it
anywhere.

The component now takes an ILogger<AdaptiveVirtualize<TItem>> and logs every
one with its exception:

- Debug for the known ways the JS side goes away. They happen routinely
  around page reloads and disconnects, and at Error every one would reach an
  app's error tracker - the noise that made the reported JSException
  visible in the first place. Sentry's logging integration, for one,
  ignores Debug by default.
- Warning for anything else caught while initialization was overtaken by
  disposal, since that catch accepts any exception and so is the one that
  could hide a genuine bug.

The four "JS side is gone" conditions are now named in one place,
IsJsSideGone, which keeps the explanation for each that used to sit in the
empty catch blocks; the instance and the module are released through the
same check. The module's catch also gains JSException, harmless where the
release itself cannot raise it. In the JS module, a failing virtual-core
cleanup is reported with console.warn instead of an empty catch.

Pinned by the dispose and failed-creation tests, which now assert the log
entry, its level and its exception. A new case, an InvalidOperationException
from create after disposal, must log at Warning. Removing the log call fails
all four.
@Kebechet
Kebechet force-pushed the fix/sfa-3058-dispose-jsexception branch from 90fdd7f to 970077e Compare October 28, 2026 13:43
The touch-fling stop skipped iOS through a copy of virtual-core's private
isIOSWebKit() user-agent check: before iOS 27 the write stops the fling
itself, and from 27 virtual-core stops it. That copy could drift from
virtual-core's own check without anyone noticing, and nothing tested the
iOS branch. It is gone: the stop now runs after every touch fling, iOS
included, where it is not needed and costs its two frames. On an iPhone 11
(iOS 26.2) a jump issued 150ms into a fling still lands three times out of
three, and the list holds still through the two hidden frames.

The fix belongs in virtual-core, which already stops a fling before a
command on iOS 27 but not on Android. It is proposed in
TanStack/virtual#1347 (issue
TanStack/virtual#1346); the README now lists
the stop as a stopgap that goes away with that release.

Matching that PR, hiding the overflow is undone in the same task when it
changes the element's client width: a classic scrollbar on a touchscreen
laptop would vanish and reflow the list for two frames. The write then
goes out at once, as before the stop existed. Pinned by a third case in
the touch-fling browser test, a scrollbar that takes layout space, which
fails without the check.
The first three version parts track the bundled @tanstack/virtual-core
release again, now 3.19.8; 3.17.7.3 was never published. Release notes
cover the teardown fixes, the virtual-core upgrade and what it changes,
the ResizeObserver and touch-fling fixes, and IsRtl.
@Kebechet
Kebechet force-pushed the fix/sfa-3058-dispose-jsexception branch from 970077e to 2c099d7 Compare October 28, 2026 23:22
@Kebechet
Kebechet merged commit 4177bf1 into main Oct 28, 2026
2 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.

2 participants