Skip to content

fix: prevent crashing due to JSException with WebView2#278

Open
SimonDD7 wants to merge 3 commits intoblazorblueprintui:developfrom
DragDay7:develop
Open

fix: prevent crashing due to JSException with WebView2#278
SimonDD7 wants to merge 3 commits intoblazorblueprintui:developfrom
DragDay7:develop

Conversation

@SimonDD7
Copy link
Copy Markdown

Description

Reloading page with some Blazor Blueprint components in standalone WebView2 app throws JSException JS object instance with ID X does not exist (has it been disposed?). It appears to be related to MAUI as I found the very same subject elsewhere - expected to be fixed but no ETA for this 2-3 year old issue.

Added code only to catch JSException on component disposals. Might not be needed here though.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)

Testing Checklist

  • Blazor Server
  • Blazor WebAssembly
  • Blazor Hybrid (MAUI)
  • Keyboard navigation / accessibility
  • Dark mode

Related Issues

Fixes similar case to #77 but with WebView2

@mathewtaylor
Copy link
Copy Markdown
Contributor

mathewtaylor commented May 2, 2026

Hi @SimonDD7, thanks for tracking this one down! The WebView2 reload crash has been a known pain point and your reproduction matches the upstream dotnet/maui issue. The pattern you're applying is the right one in spirit, just want to talk through the implementation before we merge.

A few things to address:


1. ex.Message.Contains("does not exist") is fragile

String-matching on the exception message is brittle. If the runtime ever changes that text we'd silently stop catching the case (or worse, start catching unrelated JSExceptions that share a similar message). I'd like to keep the message check (it's the only signal we have) but pull it into one place so we have a single point of truth, and tighten it up.

Something along these lines:

private static bool IsExpectedDisposeException(Exception ex) =>
    ex is JSDisconnectedException
        or TaskCanceledException
        or ObjectDisposedException
    || (ex is JSException jsEx
        && jsEx.Message.Contains("does not exist", StringComparison.Ordinal));

That way the existing exception types stay in their own bucket and the WebView2 case is explicitly tagged as a JSException with the known message. If the message ever changes we update one method, not 30+ files.


2. The same catch block is duplicated across ~30 files

This is the bigger one. The current approach copy-pastes the same 4 lines into every DisposeAsync we have. Each new component will need to remember to add it, and any future change (logging, a different message, telemetry) becomes a 30-file PR.

Let's centralize it as an extension method on IJSObjectReference. Something like:

// src/BlazorBlueprint.Components/Internal/JSObjectReferenceExtensions.cs
internal static class JSObjectReferenceExtensions
{
    /// <summary>
    /// Disposes a JS module, swallowing exceptions that are expected during
    /// circuit disconnect (Blazor Server) or page reload (MAUI WebView2).
    /// </summary>
    public static async ValueTask SafeDisposeAsync(this IJSObjectReference? module)
    {
        if (module is null)
        {
            return;
        }

        try
        {
            await module.DisposeAsync();
        }
        catch (Exception ex) when (IsExpectedDisposeException(ex))
        {
            // Expected during circuit disconnect or WebView2 page reload
        }
    }

    private static bool IsExpectedDisposeException(Exception ex) =>
        ex is JSDisconnectedException
            or TaskCanceledException
            or ObjectDisposedException
        || (ex is JSException jsEx
            && jsEx.Message.Contains("does not exist", StringComparison.Ordinal));
}

Then every call site collapses to:

public async ValueTask DisposeAsync()
{
    await _jsModule.SafeDisposeAsync();
}

For the components that do an InvokeVoidAsync("dispose", id) before tearing the module down, we can either wrap the whole block in one try and reuse IsExpectedDisposeException, or add a second helper that does invoke-then-dispose. Happy to discuss what shape works best, the main thing is that the WebView2 fix lives in one file rather than thirty.


3. The catch in BbTabsList.OnAfterRenderAsync you flagged yourself

You mentioned in the PR description that this one might not be needed. Agreed, let's drop it. The fix should stay scoped to dispose paths.


Once those are sorted this should be good to go. Thanks again for raising it and putting the work in!

Thanks, Mathew

@SimonDD7
Copy link
Copy Markdown
Author

SimonDD7 commented May 2, 2026

Hey @mathewtaylor,

  1. I haven't found a solution that targets this issue directly. There's a quality rule from Microsoft CA1065-Dispose methods. Many projects I've checked just dismiss all exceptions on component disposal - it will work but I'm not sure if that's a good practice. JSException doesn't really help here, that's why I figured string comparsion must be enough for now. Maybe drop all JSExceptions on disposals?
  2. I've seen you already discussed this with someone elsewhere and while I agree, I haven't mentioned that to not bother you with the same observation.
  3. Yea, I was checking where you dispose js stuff and I don't really have an explanation why I've put it there. Then I started PR, saw it and just pointed out in description, sorry. I should've ask AI to do it xD

Thanks for you work, this project is really cool.

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