Let's say we have a JavaScript function like this:
window.myPromiseFn = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("From JS!");
}, 1000);
});
}
Could we call that with front-end C# code written with Blazor? Something like this:
string result = await Promises.ExecuteAsync<string>("myPromiseFn");
I thought that would be completely bonkers. And also possible! Since Blazor has built-in JavaScript interop, it can call JavaScript functions. JavaScript can also call C# functions, so we can handle callbacks. Let's see how we could make this happen.
If you hate reading and just want teh codez, I have it all in a GitHub repo.
DISCLAIMER: This is an experiment built on top of an experiment. The code is by no means optimized and could use improvements.
My setup
For development I used a virtual machine in Azure with:
- Visual Studio 2017 (15.7 latest preview)
- .NET Core 2.1 Preview 1 SDK
- Blazor Language Services VS extension
You can find up to date guidelines here: https://learn-blazor.com/getting-started/getting-blazor/. It's a pretty great resource to learn about Blazor in general too.
In this article I am using Blazor 0.1.0. I will update it once 0.2.0 is released.
In VS 2017, I started with Blazor template.
Basic idea
The main idea I had was that we would create a TaskCompletionSource
,
store it, and return the Task
from it to the calling C# code.
Then we could call a JavaScript function with the name of the JS function to call, as well as any parameters. That piece of JavaScript would then call the function, add callbacks to the Promise returned, and then call a C# function once the Promise is resolved or rejected.
The C# function would then find the corresponding TaskCompletionSource
,
and set the result or error on it.
In theory this should work :)
Building the calling part
First, we need a class to contain the TaskCompletionSource
.
One reason for this is so that we can keep the type information nicely.
Since it's a generic type (TaskCompletionSource<TResult>
) where the type is different depending on the intended result,
we can only store all active ones as object
s, and would then have to use Reflection to call SetResult
with the right type.
I did it that way the first time, didn't like it.
If you want to see it, feel free to look at the first commit in the GitHub repo.
The class for containing the TaskCompletionSource
is pretty simple:
public class PromiseCallbackHandler<TResult> : IPromiseCallbackHandler
{
private readonly TaskCompletionSource<TResult> _tcs;
public PromiseCallbackHandler(TaskCompletionSource<TResult> tcs)
{
_tcs = tcs;
}
public void SetResult(string json)
{
TResult result = JsonUtil.Deserialize<TResult>(json);
_tcs.SetResult(result);
}
public void SetError(string error)
{
var exception = new Exception(error);
_tcs.SetException(exception);
}
}
It takes a TaskCompletionSource<TResult>
, and has two functions which are defined on the interface.
SetResult
is used to set a successful result.
SetError
is used when the Promise is rejected, and the Task
should be faulted.
Then we can build the function which we call here:
result = await Promises.ExecuteAsync<string>("myPromiseFn");
So let's do that.
public static class Promises
{
private static ConcurrentDictionary<string, IPromiseCallbackHandler> CallbackHandlers =
new ConcurrentDictionary<string, IPromiseCallbackHandler>();
public static Task<TResult> ExecuteAsync<TResult>(string fnName, object data = null)
{
var tcs = new TaskCompletionSource<TResult>();
string callbackId = Guid.NewGuid().ToString();
if(CallbackHandlers.TryAdd(callbackId, new PromiseCallbackHandler<TResult>(tcs)))
{
if (data == null)
{
RegisteredFunction.Invoke<bool>("runFunction", callbackId, fnName);
}
else
{
RegisteredFunction.Invoke<bool>("runFunction", callbackId, fnName, data);
}
return tcs.Task;
}
throw new Exception("An entry with the same callback id already existed, really should never happen");
}
}
The function first creates the TaskCompletionSource<TResult>
.
Then we generate an identifier for the callback so we can correlate the callback to the right handler.
We use a ConcurrentDictionary
to store the callback handlers to make the class thread-safe.
If an object was given, we pass it to the JavaScript proxy function as well, so it can be passed to function it will call.
Finally we return the Task<TResult>
from the TaskCompletionSource<TResult>
.
The JavaScript proxy
I mentioned the JavaScript proxy function just previously. It will need to:
- Call the function with the given name
- Pass it the parameters given
- Add handlers to the Promise returned
- Call C# functions in those handlers
So let's first register the proxy function (since Blazor requires you to register any JavaScript functions you want to call from C#):
Blazor.registerFunction('runFunction', (callbackId, fnName, data) => {
// Your function currently has to return something.
return true;
});
I registered this function in the MainLayout.cshtml file, not sure if that is the best place, but hey it works :) The function takes 3 parameters:
- Callback id: sent with callbacks to correlate the callback to the right
TaskCompletionSource
- Function name: the name of the JS function to call, which returns a Promise
- Data: an optional data object passed as a parameter to the JS function
Then we can add the call to the actual function:
let promise = window[fnName](data);
It just assumes the function exists in global scope. Before we create the handlers, let's get references to the C# functions we want to call:
const assemblyName = 'PromiseBlazorTest';
const namespace = 'PromiseBlazorTest';
const typeName = 'Promises';
const methodName = 'PromiseCallback';
const callbackMethod = Blazor.platform.findMethod(
assemblyName,
namespace,
typeName,
methodName
);
const errorMethodName = 'PromiseError';
const errorCallbackMethod = Blazor.platform.findMethod(
assemblyName,
namespace,
typeName,
errorMethodName
);
So we have to specify quite exactly where the functions are located.
Then we can add the handlers to the Promise:
promise.then(value => {
if (value === undefined) {
value = null;
}
const result = JSON.stringify(value);
Blazor.platform.callMethod(
callbackMethod,
null,
[
Blazor.platform.toDotNetString(callbackId),
Blazor.platform.toDotNetString(result)
]
);
}).catch(reason => {
if (!reason) {
reason = "Something went wrong";
}
const result = reason.toString();
Blazor.platform.callMethod(
errorCallbackMethod,
null,
[
Blazor.platform.toDotNetString(callbackId),
Blazor.platform.toDotNetString(result)
]
);
});
The successful callback converts the result into JSON.
It then uses the interop function Blazor.platform.callMethod
to call a static method which we will create in the next step.
Note we have to use Blazor.platform.toDotNetString()
to convert JS strings to .NET Strings.
We also pass the callbackId
to the callback so that the C# function knows which callback to trigger.
We also setup an error handler to call a different method with the error.
The completed proxy code looks like this:
const assemblyName = 'PromiseBlazorTest';
const namespace = 'PromiseBlazorTest';
const typeName = 'Promises';
const methodName = 'PromiseCallback';
const callbackMethod = Blazor.platform.findMethod(
assemblyName,
namespace,
typeName,
methodName
);
const errorMethodName = 'PromiseError';
const errorCallbackMethod = Blazor.platform.findMethod(
assemblyName,
namespace,
typeName,
errorMethodName
);
Blazor.registerFunction('runFunction', (callbackId, fnName, data) => {
let promise = window[fnName](data);
promise.then(value => {
if (value === undefined) {
value = null;
}
const result = JSON.stringify(value);
Blazor.platform.callMethod(
callbackMethod,
null,
[
Blazor.platform.toDotNetString(callbackId),
Blazor.platform.toDotNetString(result)
]
);
}).catch(reason => {
if (!reason) {
reason = "Something went wrong";
}
const result = reason.toString();
Blazor.platform.callMethod(
errorCallbackMethod,
null,
[
Blazor.platform.toDotNetString(callbackId),
Blazor.platform.toDotNetString(result)
]
);
});
// Your function currently has to return something.
return true;
});
The callback, the final piece
Next we will need those callback methods on the C# side.
Let's do the success one first in the Promises
class:
public static void PromiseCallback(string callbackId, string result)
{
if(CallbackHandlers.TryGetValue(callbackId, out IPromiseCallbackHandler handler))
{
handler.SetResult(result);
CallbackHandlers.TryRemove(callbackId, out IPromiseCallbackHandler _);
}
}
It finds the handler with the callback id, sets the result on it, and removes the handler from the dictionary.
The error handler is pretty similar:
public static void PromiseError(string callbackId, string error)
{
if (CallbackHandlers.TryGetValue(callbackId, out IPromiseCallbackHandler handler))
{
handler.SetError(error);
CallbackHandlers.TryRemove(callbackId, out IPromiseCallbackHandler _);
}
}
The only difference is that it calls SetError
on the handler.
Manually notifying that state changed
Now that we all of this, you might expect it to "just" work. And it does. However, there is one problem. If we have front-end C# code like this:
<p>Result: @result</p>
<button @onclick(RunPromise)>Click me</button>
@functions {
private string result = "";
async void RunPromise()
{
result = await Promises.ExecuteAsync<string>("myPromiseFn");
}
}
You will notice that the text will not appear on the page after the button click.
The reason is that the runtime does not know about the change.
Since the onclick handler must return void
,
it will finish before the await
call returns.
We can manually fix this problem like this:
result = await Promises.ExecuteAsync<string>("myPromiseFn");
StateHasChanged();
The call to StateHasChanged()
seems to inform the Blazor runtime that it should re-render this component.
This results in the text showing up :)
Resulting code
On the JavaScript side, I've registered a bunch of different functions:
window.myPromiseFn = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("From JS!");
}, 1000);
});
}
window.errorPromiseFn = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject("Error!");
}, 1000);
});
}
window.complexPromiseFn = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ Name: 'Joonas' });
}, 1000);
});
}
window.paramPromiseFn = (data) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.dir(data);
resolve();
}, 1000);
});
};
And the front-end C# code calling them looks like this:
private string result = "";
async void RunPromise()
{
result = await Promises.ExecuteAsync<string>("myPromiseFn");
StateHasChanged();
try
{
result = await Promises.ExecuteAsync<string>("errorPromiseFn");
}
catch(Exception e)
{
Console.WriteLine(e.Message);
}
GreetingModel model = await Promises.ExecuteAsync<GreetingModel>("complexPromiseFn");
result = "Hello " + model.Name + "!";
StateHasChanged();
await Promises.ExecuteAsync<string>("paramPromiseFn", new GreetingModel { Name = "JS" });
Console.WriteLine("Done");
}
So the only thing that kinda sucks is that we have to notify the runtime of changes every time. But is this awesome or what?
Summary
Who could ever have thought we could use C#'s await
on a JavaScript Promise!
Personally I don't know if Blazor will be a big thing,
but WebAssembly-based frameworks will be popping up more as time goes on.
And if WebAssembly's capabilities are increased to manipulate the DOM
and all the other things that JS is used for,
maybe we could get rid of JavaScript entirely on the front-end one day?
Just a thought.
Thanks for reading, and see you next time! Don't hesitate to drop a comment :)