Repository navigation
Teardown fixes, virtual-core 3.19.8, IsRtl and touch-fling fixes (3.19.8) - #1
Merged
Merged
Conversation
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.
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
force-pushed
the
fix/sfa-3058-dispose-jsexception
branch
from
October 28, 2026 13:33
505c832 to
90fdd7f
Compare
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
force-pushed
the
fix/sfa-3058-dispose-jsexception
branch
from
October 28, 2026 13:43
90fdd7f to
970077e
Compare
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
force-pushed
the
fix/sfa-3058-dispose-jsexception
branch
from
October 28, 2026 23:22
970077e to
2c099d7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Started as the fix for
DisposeAsyncthrowingJSException: 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
WebViewManager.AttachToPageAsync), so the old renderer'sdisposecall reaches a page that never created the object.importorcreatestill in flight at disposal.createthat fails after disposal.virtual-core 3.19.8
_willUpdate()once the container has grown, so a chat-style list stays pinned to the bottom.CancelScroll, the oneVirtualizermember that is new since 3.17.7.Found by testing on real devices
ResizeObserver loop completed with undelivered notificationsas 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.IsRtlwork for horizontal lists. It now places items from the right edge and negates scroll writes. The demo's container is nowdirection: rtl.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 aconsole.warn.Testing
After merge
Run the Publish to NuGet workflow to release 3.19.8.