Typed exceptions for JSInterop in Blazor
When developing applications in Blazor we can invoke JavaScript function from C# by using JSInterop. Many types of errors can be thrown when calling these methods. Especially if something unexpected happens like missing permissions or if one of the arguments is invalid. The errors thrown from JS get bubbled up to our method invocation in C# and we can catch these JSExceptions using a try-catch statement. The exception contain the message of the original error, but we lose the context of which error type this was which has led people to do a lot of custom error handling that instead looks for key phrases or words in the error message. In this article, we look at how the existing error-handling implementation is made in Blazor. We then look at how we can map the error types defined in the standard WebIDL specification to C# exceptions, and in the end, we show some examples of how to use this in practice when making JS invocations.
Classic example of handling exceptions from JSInterop
With the current way that Blazor propagates errors to us, we are often left with some rather simple ways of finding out what actually happened. Let's see what happens when we try to call some JS that will fail. The example we will work with is invoking a method that does not exist but which we expect might throw a JS AbortError
.
@inject IJSRuntime JSRuntime
@code {
protected override async Task OnInitializedAsync()
{
await JSRuntime.InvokeVoidAsync("nonExistingMethod");
}
}
We will see the following error in the console of our browser.
crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
Unhandled exception rendering component: Could not find 'nonExistingMethod' ('nonExistingMethod' was undefined).
Error: Could not find 'nonExistingMethod' ('nonExistingMethod' was undefined).
at http://localhost:5278/_framework/blazor.webassembly.js:1:328
at Array.forEach (<anonymous>)
at a.findFunction (http://localhost:5278/_framework/blazor.webassembly.js:1:296)
... This continues with a very long stack trace that is not relevant to the exception. Not until this part which is from the .NET call stack:
at Microsoft.JSInterop.JSRuntime.<InvokeAsync>d__16`1[[Microsoft.JSInterop.Infrastructure.IJSVoidResult, Microsoft.JSInterop, Version=7.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60]].MoveNext()
at Microsoft.JSInterop.JSRuntimeExtensions.InvokeVoidAsync(IJSRuntime jsRuntime, String identifier, Object[] args)
at test_project.Pages.Index.OnInitializedAsync() in F:\repos\test-project\Pages\Index.razor:line 8
at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)
And if we try to capture the exception and read its Message
we still get the same exception except we don't get the .NET call stack.
@inject IJSRuntime JSRuntime
<pre><code>
@exceptionMessage
</code></pre>
@code {
string? exceptionMessage;
protected override async Task OnInitializedAsync()
{
try
{
await JSRuntime.InvokeVoidAsync("nonExistingMethod");
}
catch (JSException jsex)
{
exceptionMessage = jsex.Message;
}
}
}
And this message isn't really a nice user-facing error to get. So what we have done earlier is either to forget entirely what the exception said and just write something like "Something bad happened!" which doesn't help anyone. Or the better case which is to check for matching parts of the exception message. That could look something like this if we expect that JS could either throw an AbortError
or some other unexpected exception.
@inject IJSRuntime JSRuntime
@inject ILogger Logger
<span style="color: red;">@exceptionMessage</span>
@code {
string? exceptionMessage;
protected override async Task OnInitializedAsync()
{
try
{
await JSRuntime.InvokeVoidAsync("nonExistingMethod");
}
catch (JSException jsex)
{
if (jsex.Message.Contains("is aborted"))
{
exceptionMessage = "The execution of the method was canceled.";
}
else
{
exceptionMessage = "An unexpected error happened.";
Logger.LogError(exceptionMessage, jsex);
}
}
}
}
This is a bit hand-wavy. We first need to know that the exception message contained the words "is aborted"
and we then have to hope that no other exception could happen that would also contain those words which would make for a false positive.
How Blazor currently handles errors in JSInterop
It can be tough to find out why an error happens when an invocation is not successful as we saw above. The root of the problem is that we don't get all information about why an error happens when Blazor handles an error. Most noticeably we don't get information about the name
of the specific type of error. To find out why let's look at the TypeScript code that handles Blazors dynamic invocation of functions in the beginInvokeJSFromDotNet function in Microsoft.JSInterop.ts:
beginInvokeJSFromDotNet: (asyncHandle: number, identifier: string, argsJson: string, resultType: JSCallResultType, targetInstanceId: number): void => {
// Coerce synchronous functions into async ones, plus treat
// synchronous exceptions the same as async ones
const promise = new Promise<any>(resolve => {
const synchronousResultOrPromise = findJSFunction(identifier, targetInstanceId).apply(null, parseJsonWithRevivers(argsJson));
resolve(synchronousResultOrPromise);
});
// We only listen for a result if the caller wants to be notified about it
if (asyncHandle) {
// On completion, dispatch result back to .NET
// Not using "await" because it codegens a lot of boilerplate
promise
.then(result => stringifyArgs([asyncHandle, true, createJSCallResult(result, resultType)]))
.then(
result => getRequiredDispatcher().endInvokeJSFromDotNet(asyncHandle, true, result),
error => getRequiredDispatcher().endInvokeJSFromDotNet(asyncHandle, false, JSON.stringify([
asyncHandle,
false,
formatError(error)
]))
);
}
},
They start off by making any potential synchronous method asynchronous by wrapping it in a promise. In the promise, they follow the identifier path to the function that should be invoked and then apply the arguments to that function using the apply
method. This is all good and basically just means that they have fewer places in the code that varies. Next, they resolve the newly created promise and handle the two outcomes of the function. Either it was successful or it errored. In either case, we call back to .NET with some payload and a flag indicating if the invocation was successful. If it was successful we simply serialize the result and return that. But when it errors we instead call and return the result of the method formatError function in Microsoft.JSInterop.ts:
function formatError(error: Error | string): string {
if (error instanceof Error) {
return `${error.message}\n${error.stack}`;
}
return error ? error.toString() : "null";
}
This is what formats the error in the well-known format we see in the browser console or the JSException
's Message
when making JSInteorp calls. Do you see what we are missing? Yes exactly, the name
of the specific Error
. We have lost the type of the original error. So that is exactly what we want to get back so that we can handle the original grouping of errors instead of resorting to matching on parts of the message.
Mapping JS errors to C# exceptions
We want to do something similar to what the standard JSInterop has already done but include some other information. Sadly we can't change or use the existing code for the JSInterop functionality as most of it is internal. So instead we need to wrap the existing implementations and work around some of the mechanisms. But before we go there let's look at what different kinds of errors there are in JS.
Overview of JS error types
There are 6 types of errors that we should be concerned with when working within the browser.
- EvalError
- RangeError
- ReferenceError
- SyntaxError
- TypeError
- URIError
Each of these is used in a different scenario and can also be referred to as Native Error Types.
Apart from these we also have some standard exceptions called the DOMException
s. They are generally used for errors that happens as an effect of the user, the browser, or the system doing something unexpected.
DOMException
s are also errors, so we will simply refer to both JS errors and JS exceptions as errors from now on. There are many different kinds of DOMExceptions
as well that are defined in the WebIDL standard specifications. They differ only by having different names and then of cause by being used in different scenarios. There are 29
predefined error names that are currently valid, so let's not list them all. The ones you might already know could be the NotFoundError
, the NotSupportedError
, and of cause the AbortError
.
Capturing and rethrowing in JavaScript
The first step after having made clear that we are interested in the name of the exceptions is to somehow get this information into the exception that gets thrown at us. To do this we need to catch all exceptions on the JS side and rethrow them back for the Blazor implementation to handle. We could do this in all places where we need to invoke a method, but that would mean that we would need custom JS wrapper methods for ALL JS invocations we wanted to make. Instead, let's make a simple method that can execute any general method similar to how the Blazor implementation does it, but then throw another exception. We make a new JS module (a JS file with exported functions) and start with the following function which we will import and call later.
export async function callAsyncGlobalMethod(identifier, args) {
return await callAsyncInstanceMethod(window, identifier, args);
}
The method has two parameters. The first is the identifiers for the method that we will invoke and the second is the arguments that we will apply to this method. It then simply calls another method that we will define now and parses the window
object to this method. The window
object is the topmost context for the browser and thereby exposes most methods that you can otherwise invoke. So you could call alert("Hello!")
or you could call window.alert("Hello!")
and get the same result.
export async function callAsyncInstanceMethod(instance, identifier, args) {
try {
var [functionObject, functionInstance] = resolveFunction(instance, identifier);
return await functionInstance.apply(functionObject, args);
}
catch (error) {
throw new DOMException(formatError(error), "AbortError");
}
}
This function starts off by creating a try-catch
statement. In the try
block we call a function that resolves the reference to our function. Then it actually applies the arguments to the function, awaits it, and returns that. If it throws an error while resolving or awaiting the function we hit the catch
block. We capture the error
in the catch
block and throw a new error. The new error is a DOMException
constructed with the message given from calling formatError
with the error
and the name
set to "AbortError"
. It actually doesn't matter what type of error we throw as we already know that the Blazor code will completely ignore the name
, but we chose an AbortError
as that fits what we have done. Now let's first look at the resolveFunction
function that we used to get the function instance and the function object.
function resolveFunction(instance, identifier)
{
let identifierParts = identifier.split(".");
var functionObject = instance;
var functionInstance = instance[identifierParts[0]];
for (let i = 1; i < identifierParts.length; i++) {
if (functionInstance == undefined) {
throw new ReferenceError(`Cannot read properties of undefined (reading '${identifierParts[i - 1]}').`);
}
functionObject = functionInstance;
functionInstance = functionInstance[identifierParts[i]];
}
if (!(functionInstance instanceof Function)) {
throw new TypeError(`'${identifierParts.slice(-1)}' is not a function.`);
}
return [functionObject, functionInstance];
}
resolveFunction
first splits the identifier into its parts. This is to support when calling methods on attributes like window.caches.open
with a single invocation. We then set that the object that the function should be called on (functionObject
) is the instance
parsed as a start and that the function itself is the property with the name matching the first part of the identifier. Then we go through all consecutive identifier parts (if there are any left) and update the functionObject
to be the functionInstance
of the previous iteration, as it was actually an attribute, and once again set the functionInstance
to the next part of the identifier. Finally, we return the object that the function should be called on and the function itself. We do this as functions in JS always have to be called in some context.
If an error were to happen when invoking the returned function or while resolving it then we format that error and re-throw it. Below we see the function we use to format the error. It is a rather simple function that simply returns a stringified object containing the error name
, message
, and stack
. This is similar to what Blazor does, but we use JSON as our structure instead of concatenating the details and we also include the name
.
function formatError(error) {
var name = error.name;
if (error instanceof DOMException && name == "SyntaxError") {
name = "DOMExceptionSyntaxError";
}
return JSON.stringify({
name: error.name,
message: error.message,
stack: error.stack
})
}
The one special thing we do is check if it is a DOMException
and if the name
is "SyntaxError"
. In that case we prepend the name with "DOMException"
as to disambiguate it from the SyntaxError
error type that also has this name. This is important as they have different purposes despite sharing a name.
And now the JS module is done and ready to be imported from C#.
Capturing and rethrowing in C#
The C# part should be a reusable class that follows the same pattern as the existing IJSRuntime
so that it will be easy to use instead. And how lucky can we be? IJSRuntime
is just an interface that we can implement. We create a new class called ErrorHandlingJSRuntime
that implements IJSRuntime
.
public class ErrorHandlingJSRuntime : IJSRuntime
{
public ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(string identifier, object?[]? args)
{
throw new NotImplementedException();
}
public ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
{
throw new NotImplementedException();
}
}
Before we implement the methods let's create a constructor that sets up the members we will need to make invocations.
private Lazy<Task<IJSObjectReference>> helperTask;
public ErrorHandlingJSRuntime(IJSRuntime jSRuntime)
{
helperTask = new(async () =>
await jSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/KristofferStrube.Blazor.WebIDL/KristofferStrube.Blazor.WebIDL.js")
);
}
We take an IJSRuntime
in the constructor and create a new lazily loaded IJSObjectReference
helper task using it. The helper is created once needed by importing the module we made in the previous section. Now we are ready to implement the methods. The two methods are very similar but differ by having an extra CancellationToken
argument that can be used to cancel an invocation. An example of a time you would cancel an invocation is if the user navigates away from the page before the invocation is done. This makes the first method easy to implement as we can simply call the other method with the default value for the CancellationToken
.
public ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(string identifier, params object?[]? args)
{
return InvokeAsync<TValue>(identifier, CancellationToken.None, args);
}
You might have questioned the rather long signature for the methods by now. Especially the very long DynamicallyAccessedMembers
attribute added to the generic type parameter. These are used to tell that there will be used reflection on the types passed to the method. This is relevant in cases where you use AOT compilation as that will normally not allow reflection, but this makes it clear to the compiler that there will be used reflection on these types. It needs reflection since System.Text.Json
goes through all properties and constructors when deserializing.
But now to the method that makes the invocation.
public async ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(string identifier, CancellationToken cancellationToken, params object?[]? args)
{
var helper = await helperTask.Value;
try
{
return await helper.InvokeAsync<TValue>("callAsyncGlobalMethod", cancellationToken, identifier, args);
}
catch (JSException exception)
{
if (UnpackMessageOfExeption(exception) is not Error { } error)
{
throw;
}
throw MapToWebIDLException(error, exception);
}
}
We first resolve our lazy-loaded helper module and then we start a try-catch
statement. In the try
block we invoke the callAsyncGlobalMethod
method from our helper module and parse in the CancellationToken
that might be the default None
value and the original identifier
and args
arguments as the args
parameter for the InvokeAsync
method on our helper module. If it is successful then that will be it and we just return the value. But if it fails then we hit the catch
block. We only catch JSException
as we don't want to handle any other errors like JSON serialization errors or the like. In the block, we call the method UnpackMessageOfExeption
that has the task of deserializing the Message
we packed in the JS function formatError
. If it could not unpack it then we throw the original exception as it clearly was not an exception that we had formatted. If we could unpack it then we map the Error
and the JSException
to some other Exception
type and throw that instead. Let's first implement the UnpackMessageOfExeption
method.
internal Error? UnpackMessageOfExeption(JSException exception)
{
return JsonSerializer.Deserialize<Error?>(exception.Message[..^9].Trim());
}
We first trim the last 9
characters which are "undefined"
as this is the stack
that Blazor postfixes the message
with (because we threw the exception) and then we call Trim
to remove the line break. Then we use System.Text.Json
to deserialize the payload as an Error?
and return that. Pretty straightforward. Now to the MapToWebIDLException
method that maps this Error
to a new Exception
.
internal WebIDLException MapToWebIDLException(Error error, JSException exception)
{
if (ErrorMapper.TryGetValue(error.name, out Func<string, string, string?, Exception, WebIDLException>? creator))
{
return creator(error.name, error.message, error.stack, exception);
}
else
{
return new WebIDLException($"{error.name}: \"{error.message}\"", null, exception);
}
}
The method checks inside a dictionary for a key equal to the error name. The type of the dictionary is Dictionary<string, Func<string, string, string?, Exception, WebIDLException>>
but we will get back to that later. The value returned from the dictionary is a Func
that takes 3 strings and an Exception
and parses back a WebIDLException
. If we successfully find a key that matches then we invoke the Func
that is in the value by parsing the name
, the message
, the stack
, and the original JSException
to this. If we could not find a matching key then we simply return a WebIDLException
. This is an Exception
type that we have implemented to give all Exceptions
from this ErrorHandlingJSRuntime
a common type they can be derived from.
/// <summary>
/// A common exception class for all exceptions from the <c>Blazor.WebIDL</c> library.
/// </summary>
public class WebIDLException : Exception
{
private readonly string? jSStackTrace;
/// <summary>
/// Returns the stack trace as a string. The stack is prepended with the JS stack trace if there is any. If no stack trace is available, null is returned.
/// </summary>
public override string? StackTrace => jSStackTrace is not null ? jSStackTrace + '\n' + base.StackTrace : base.StackTrace;
/// <summary>
/// Constructs a wrapper Exception for the given error.
/// </summary>
/// <param name="message">User agent-defined value that provides human readable details of the error.</param>
/// <param name="jSStackTrace">The stack trace from JavaScript if there is any.</param>
/// <param name="innerException">Inner exception which is the cause of this exception.</param>
public WebIDLException(string message, string? jSStackTrace, Exception innerException) : base(message, innerException)
{
this.jSStackTrace = jSStackTrace;
}
}
The WebIDLException
has a single constructor that parses the message
and innerException
down to the base Exception
and sets the field jSStackTrace
with the potential stack trace. If the jSStackTrace
is not null then it is prepended to the base StackTrace
. Our goal is to map each standard error name to some Exception
type that inherits from WebIDLException
. For this purpose, we define the dictionary ErrorMapper
as a public property on the ErrorHandlingJSRuntime
.
public Dictionary<string, Func<string, string, string?, Exception, WebIDLException>> ErrorMapper { get; set; } = ErrorMappers.Default;
We define a static readonly default dictionary that maps the ones defined in the specifications. This opens up for anyone to add to the error mapper if they need to support some custom JS error type. There are a lot of predefined errors in JS as we have seen previously so I will not show all the different Exception
types I have created. But I have grouped them so that all the Native Error Types like ReferenceErrorException
and TypeErrorException
are derived from a common NativeErrorException
and all the different DOMException
s are derived from a common DOMException
class.
/// <summary>
/// An exception that encapsulates a name for an exception.
/// </summary>
/// <remarks><see href="https://webidl.spec.whatwg.org/#idl-DOMException">See the WebIDL definition here</see></remarks>
public class DOMException : WebIDLException
{
public const string NotFoundError = "NotFoundError";
public const string SyntaxError = "DOMExceptionSyntaxError";
public const string AbortError = "AbortError";
// Constants for all the other predefined `DOMException` names are also included here but have been omitted for brevity.
/// <summary>
/// Error name which should be one of the ones listed in this <see href="https://webidl.spec.whatwg.org/#dfn-error-names-table">error names table</see>.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Constructs a wrapper Exception for the given error.
/// </summary>
/// <param name="message">User agent-defined value that provides human readable details of the error.</param>
/// <param name="name">Error name which should be one of the ones listed in this <see href="https://webidl.spec.whatwg.org/#dfn-error-names-table">error names table</see>.</param>
/// <param name="jSStackTrace">The stack trace from JavaScript if there is any.</param>
/// <param name="innerException">Inner exception which is the cause of this exception.</param>
protected DOMException(string message, string name, string? jSStackTrace, Exception innerException) : base(message, jSStackTrace, innerException)
{
Name = name;
}
}
The DOMException
defines a lot of constants for the different names of errors, but also adds a property Name
that defines the error name. The constructor is protected since one should always define a derived Exception
type for each additional custom DOMException
. A concrete example of a DOMException
could be the NotFoundErrorException
.
/// <summary>
/// The object can not be found here.
/// </summary>
/// <remarks><see href="https://webidl.spec.whatwg.org/#notfounderror">See the WebIDL definition here</see></remarks>
public class NotFoundErrorException : DOMException
{
/// <summary>
/// Constructs a wrapper Exception for the given error.
/// </summary>
/// <param name="message">User agent-defined value that provides human readable details of the error.</param>
/// <param name="jSStackTrace">The stack trace from JavaScript if there is any.</param>
/// <param name="innerException">Inner exception which is the cause of this exception.</param>
public NotFoundErrorException(string message, string? jSStackTrace, Exception innerException) : base(message, NotFoundError, jSStackTrace, innerException) { }
}
Now we are ready to define our Default
ErrorMapper
.
public static class ErrorMappers
{
public static Dictionary<string, Func<string, string, string?, Exception, WebIDLException>> Default { get; } = new()
{
{ DOMException.NotFoundError, (name, message, jSStackTrace, innerException) => new NotFoundErrorException(message, jSStackTrace, innerException) },
{ DOMException.SyntaxError, (name, message, jSStackTrace, innerException) => new SyntaxErrorDOMException(message, jSStackTrace, innerException) },
{ DOMException.AbortError, (name, message, jSStackTrace, innerException) => new AbortErrorException(message, jSStackTrace, innerException) },
// Also includes all other DOMException types, but they have been omitted from the article for brevity again.
{ "EvalError", (name, message, jSStackTrace, innerException) => new EvalErrorException(message, jSStackTrace, innerException) },
{ "RangeError", (name, message, jSStackTrace, innerException) => new RangeErrorException(message, jSStackTrace, innerException) },
{ "ReferenceError", (name, message, jSStackTrace, innerException) => new ReferenceErrorException(message, jSStackTrace, innerException) },
{ "SyntaxError", (name, message, jSStackTrace, innerException) => new TypeErrorException(message, jSStackTrace, innerException) },
{ "TypeError", (name, message, jSStackTrace, innerException) => new TypeErrorException(message, jSStackTrace, innerException) },
{ "URIError", (name, message, jSStackTrace, innerException) => new URIErrorException(message, jSStackTrace, innerException) },
};
}
The Default
mapper is pretty straight forward as we would expect so now we are done with implementing the ErrorHandlingJSRuntime
. Let's make a small sample of using this to call a function that will fail and then catch the exception.
@inject IJSRuntime JSRuntime
<span style="color: red;">@exceptionMessage</span>
@code {
string? exceptionMessage;
protected override async Task OnInitializedAsync()
{
try
{
var errorHandlingJSRuntime = new ErrorHandlingJSRuntime(JSRuntime);
var url = await errorHandlingJSRuntime.InvokeAsync<string>("URL.createObjectURL", "not a blob");
}
catch (TypeErrorException ex)
{
exceptionMessage = $"Wrong type for argument. {ex.Message}";
}
catch (WebIDLException ex)
{
exceptionMessage = $"An {ex.GetType().Name} happened: \"{ex.Message}\"";
}
catch (Exception)
{
exceptionMessage = "An unexpected error happened.";
}
}
}
In the above example, we try to call the createObjectURL
method from the browser File API. This method takes a Blob
, a File
, or a MediaSource
as its argument, but we passed a string to it. So it throws a TypeErrorException
since there was no overload for the method taking a string. This results in the following message in our app:
Wrong type for argument. Failed to execute 'createObjectURL' on 'URL': Overload resolution failed.
Blazor.WebIDL
As the JS errors are declared in the WebIDL standard specifications I have included the work that we have done above in my Blazor.WebIDL package. You can add it to your project by running the following in the terminal while located in the folder of your Blazor project.
dotnet add package KristofferStrube.Blazor.WebIDL
And instead of having to construct the ErrorHandlingJSRuntime
yourself, I've added an extension method that adds the needed services to the service collection so that you can just inject the ErrorHandlingJSRuntime
as an IErrorHandlingJSRuntime
in the pages that need it.
builder.Services.AddErrorHandlingJSRuntime();
I've made some more simplifications that make it easier to use. But for this to work we also need to call and await the extension method SetupErrorHandlingJSInterop
on our ServiceProvider
after we have Build
the application, but before we run it.
var app = builder.Build();
await app.Services.SetupErrorHandlingJSInterop();
await app.RunAsync();
Then we can inject it into some Blazor page and use it directly to make invocations.
@inject IErrorHandlingJSRuntime ErrorHandlingJSRuntime
<button @onclick="ReadClipboard">Copy from clipboard</button>
<br />
@if (result is not null)
{
<p>You have the text: '@result' in your clipboard</p>
}
else
{
<code>@copyError</code>
}
@code {
private string? result;
private string copyError = string.Empty;
private async Task Copy()
{
try
{
result = await ErrorHandlingJSRuntime.InvokeAsync<string>("navigator.clipboard.readText");
}
catch (NotAllowedErrorException)
{
copyError = "The user has not given permission to read the clipboard.";
}
catch (DOMException exception)
{
copyError = $"{exception.Name} (which is a DOMException): \"{exception.Message}\"";
}
catch (Exception)
{
copyError = "An unexpected error happened.";
}
}
}
In the above sample, we try to read the content of the clipboard. You might not have given the site permission to do this and in this case, a NotAllowedErrorException
will be thrown. If it wasn't this specific kind of DOMException
that happened then it might have been some other DOMException
type which is the next kind we try to catch and in the end, if it wasn't any of these we give back a general error message.
I have also added error handling wrappers for the IJSInProcessRuntime
, IJSObjectReference
, and IJSInProcessObjectReference
types and made sure that you get back an appropriate error handling version of an IJSObjectReference
if this is the TValue
type that you return from an invocation. So in total, we now have the following error-handling JSInterop interfaces that we can inject.
IErrorHandlingJSRuntime
IErrorHandlingJSInProcessRuntime
IErrorHandlingJSObjectReference
IErrorHandlingJSInProcessObjectReference
The reason why we need to call the SetupErrorHandlingJSInterop
was so that we could return IErrorHandlingJSObjectReference
s without having to resolve the helper asynchronously when creating it but also so that we can use the IErrorHandlingJSInProcessRuntime
when using Blazor WASM to make synchronous JS invocations without using some asynchronous factory to create the runtime each time it is needed.
Future plans
The plan is to also make an error-handling version of the IJSUnmarshalledRuntime
in the future. And I intend to upgrade all my existing browser API packages Blazor.FileSystemAccess, Blazor.FileSystem, Blazor.FileAPI, Blazor.Streams, Blazor.CompressionStreams, Blazor.WebAudio, Blazor.MediaCaptureStreams, Blazor.DOM and all my future ones to use the error-handling versions when they make JSInterop calls.
Conclusion
At the start of this post, we saw some of the existing approaches for handling different types of exceptions when making JSInterop calls in Blazor. And we now know that this is because we don't get the name of the exception extracted when the error is returned to C#. We then found a way to catch more details of the error on the JS side of the invocation and re-throw these details back to Blazor where we could then unpack the name, message, and stack trace separately and map them to custom C# exception types. In the end, I presented the Blazor.WebIDL package that has made this ErrorHandlingJSRuntime
easy to add to any project where you want to have typed exceptions when using JSInterop. We also went through a small example of using the package to make handle different error scenarios. I would be very happy if people want to test the package and give feedback on the GitHub repository so that I can make sure that it is both easy and safe to use. The library is still in alpha while I test it in some of my libraries, but there won't go long before I make a first full release. And as always, if you have any feedback or questions for this post then feel free to reach out to me on Mastodon or Twitter.