Eilon / MauiHybridWebView

MIT License
206 stars 44 forks source link

Unable to call async JS method and get result #74

Open rbrundritt opened 1 month ago

rbrundritt commented 1 month ago

I've found that if you call an async JS function you can't wait for the response currently. I managed to work around this, but before making a pull request for this I wanted to show how I was addressing this and get feedback.

Here is a proposal for a new method called InvokeAsyncJsMethodAsync which creates a task ID for async JS requests and TaskCompletion (there is some additional code here to address other issues I encountered, like https://github.com/Eilon/MauiHybridWebView/issues/72 and I also needed custom JsonSerializerOptions)

public class MyJsInterlop
{
    private int taskCounter = 0;
    private Dictionary<string, TaskCompletionSource<string>> asyncTaskCallbacks = new Dictionary<string, TaskCompletionSource<string>>();

    //Optional: custom serialization options.
    private JsonSerializerOptions? _jsonSerializerOptions;

    public MapJsInterlop(JsonSerializerOptions? jsonSerializerOptions = null)
       {
        _jsonSerializerOptions = jsonSerializerOptions;
    }

    /// <summary>
    /// Handler for when the an Async JavaScript task has completed and needs to notify .NET.
    /// </summary>
    /// <param name="eventData"></param>
    public void AsyncTaskCompleted(string taskId, string result)
    {
        //Look for the callback in the list of pending callbacks.
        if (!string.IsNullOrEmpty(taskId) && asyncTaskCallbacks.ContainsKey(taskId))
        {
            //Get the callback and remove it from the list.
            var callback = asyncTaskCallbacks[taskId];
            callback.SetResult(result);

            //Remove the callback.
            asyncTaskCallbacks.Remove(taskId);
        }
    }

     public async Task<string> InvokeAsyncJsMethodAsync(string methodName, params object[] paramValues)
     {
         try
         {
             if (string.IsNullOrEmpty(methodName))
             {
                 throw new ArgumentException($"The method name cannot be null or empty.", nameof(methodName));
             }

             //Create a callback.
             var callback = new TaskCompletionSource<string>();

             var taskId = $"{taskCounter++}";

             asyncTaskCallbacks.Add(taskId, callback);

             string paramJson = GetParamJson(paramValues);

             await _webView.EvaluateJavaScriptAsync($"{methodName}({paramJson}, \"{taskId}\");");

             return await callback.Task;
         }
         catch (Exception ex)
         {
             Debug.WriteLine($"Error invoking async method: {ex.Message}");
             return string.Empty;
         }
     }

     public async Task<TReturnType?> InvokeAsyncJsMethodAsync<TReturnType>(string methodName, params object[] paramValues)
     {
         try
         {
             var stringResult = await InvokeAsyncJsMethodAsync(methodName, paramValues);

             if (string.IsNullOrWhiteSpace(stringResult) || stringResult.Equals("null") || stringResult.Equals("{}"))
             {
                 return default;
             }

             return JsonSerializer.Deserialize<TReturnType>(stringResult, Constants.MapJsonSerializerOptions);
         }
         catch (Exception ex)
         {
             Debug.WriteLine($"Error invoking async method: {ex.Message}");
             return default;
         }
     }

     /// <summary>
     /// Gets the JSON string and JsonSerializerOptions for the parameters. 
     /// </summary>
     private string GetParamJson(object[] paramValues)
     {
         string paramJson = string.Empty;

         if (paramValues != null && paramValues.Length > 0)
         {
             paramJson = string.Join(", ", paramValues.Select(v => JsonSerializer.Serialize(v, _jsonSerializerOptions)));
         }

         return paramJson;
     }
 }

From the JavaScript side of things I need to send a message back when the async task has completed.

/**
* Notifies .NET code that an async callback in JavaScript has completed.
* @param {string} taskId The task id of the async operation.
* @param {any} result The result of the async operation.
*/
function triggerAsyncCallback(taskId, result) {
    //Make sure the result is a string.
    if (result && typeof (result) !== 'string') {
        result = JSON.stringify(result);
    } else {
        result = '';
    }

    HybridWebView.SendInvokeMessageToDotNet('AsyncTaskCompleted', [taskId, result]);
}

Now lets assume I have this simple async JavaScript function:

async function simpleAsyncFunction(taskId){
    const myPromise = new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve("times up!");
      }, 300);
    });

    var result = await myPromise;

    triggerAsyncCallback(taskId, result);
}

I can now call this from .NET and wait for it to asynchronously complete.

var result = await InvokeAsyncJsMethodAsync("simpleAsyncFunction", "Bob");

//We will no reach this point until the async JS function has responded.

I've tested this in both Windows and Android with success.

A couple of thoughts for feedback/improvement:

  1. I tried to keep the .NET method name similar but InvokeAsyncJsMethodAsync sounds weird. Any suggestions?
  2. Possibly add timeout logic for the async tasks on the .NET side.
  3. Possibly wrap the JS method call so the developer doesn't need to be aware of the taskId. Possibly something like this:
await _webView.EvaluateJavaScriptAsync($"(async function() {{var result = await {methodName}({paramJson}); triggerAsyncCallback(\"{taskId}\", result);}})()");

Now that I write the above, I'm thinking that a bit more javascript logic could be added that checks to see if the method is async and if so, then await it. If I could get that working, then we would only need to update the original InvokeJsMethodAsync method and make this a seamless update for users.

rbrundritt commented 1 month ago

Looks like it is pretty easy to determine if a JS function is async or not. For example:

async function test() {
     return 1;
} 

if(test[Symbol.toStringTag] === 'AsyncFunction'){
     console.log('is async');
} else {
     console.log('inot async');
}

So with that in mind, the EvaluateJavaScriptAsync could be updated to the following within the existing InvokeJsMethodAsync method to the following:

await _webView.EvaluateJavaScriptAsync($"(var params = [{paramJson}]; if({methodName}[Symbol.toStringTag] === 'AsyncFunction'){{ async function() {{ var result = await {methodName}(...params}); triggerAsyncCallback(\"{taskId}\", result); }} }} else {{ return {methodName}(...params); }})

We would need to still add the task creation logic.

Eilon commented 1 month ago

@rbrundritt ooooh this looks interesting! I think I was just noticing this same issue while testing the .NET MAUI official HybridWebView, so I'll try out your suggestion and see if I can get it working end-to-end!

rbrundritt commented 1 month ago

I saw somewhere, I think it was for WebView1, that you could just add an await in front of the async function method name and it should work. I'm going to test that now. That would be much better than using this task ID approach I started out with.

Eilon commented 1 month ago

I'm pretty sure I tried doing await but it seemed the function 'returned' immediately and you wouldn't get the result back at all.

Eilon commented 1 month ago

So I think that probably the JS code has to do the await and then send some kind of message back to C#, and then C# can process the result.

rbrundritt commented 1 month ago

This looked promising: https://stackoverflow.com/questions/76817407/calling-async-function-in-webview2

But in the end it still didn't work for me. It looks like there is a known bug in WebView2: https://github.com/MicrosoftEdge/WebView2Feedback/issues/2295 There workaround sounds to be to use "DevTools protocol to evaluate your JavaScript" but that doesn't sound ideal. Also not sure if there is similar issues with the other platform browsers.

rbrundritt commented 1 month ago

On a related not I also found that the EvaluateJavaScriptAsync method fails silently if your JSON has any properties with HTML strings or special characters (unicode). I tried different JSON serializer options but those didn't work (also didn't like the idea of setting the encoding to System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping). Here is a related thread: https://stackoverflow.com/questions/58003293/dotnet-core-system-text-json-unescape-unicode-string

I did however find a simple solution. I take the serialized paramJson string and convert it to base64, then in the EvaluateJavaScriptAsync method I convert back to a normal string then JSON object using JSON.parse andatob`. Here is the code:

paramJson = Convert.ToBase64String(Encoding.UTF8.GetBytes(paramJson));

return await _webView.EvaluateJavaScriptAsync($"{methodName}(...JSON.parse('[' + atob('{paramJson}') + ']'))");
rbrundritt commented 1 month ago

After a bunch of testing I'm going to use the task completion method for now in my app as that seems to be more reliable. I have to testing it a bit more on other platforms. To simplify my code I combined the logic into a single InvokeJsMethodAsync method like this:

public async Task<string> InvokeJsMethodAsync(string methodName, params object[] paramValues)
{
    try
    {
        if (string.IsNullOrEmpty(methodName))
        {
            throw new ArgumentException($"The method name cannot be null or empty.", nameof(methodName));
        }

        //Create a callback.
        var callback = new TaskCompletionSource<string>();

        var taskId = UniqueId.Get("asyncMapTask");

        asyncTaskCallbacks.Add(taskId, callback);

        string paramJson = GetParamJson(paramValues);

        //Base64 encode parameters to workaround some known issues in the WebView2 control. (html strings and special characters cause EvaluateJavaScriptAsync to fail silently.
        paramJson = Convert.ToBase64String(Encoding.UTF8.GetBytes(paramJson));

        await _webView.EvaluateJavaScriptAsync($"(async () => {{var paramJson = JSON.parse('[' + atob('{paramJson}') + ']'); var result = ({methodName}[Symbol.toStringTag] === 'AsyncFunction') ? await {methodName}(...paramJson) : {methodName}(...paramJson); triggerAsyncCallback(\"{taskId}\", result); }})()");

        return await callback.Task;
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"Error invoking async method: {ex.Message}");
        return string.Empty;
    }
}

The following does convert the paramJson to a base64 string. I'm sure this does create a bit of overhead, however, in testing it seems to be negligible (I played around with sending and receiving data that's around ~50mb in size using the above code).

I'm using the following to generate the paramJson string initially:

private string GetParamJson(object[] paramValues)
{
    JsonSerializerOptions jsonSerializerOptions = new JsonSerializerOptions
    {
        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault
    };

    string paramJson = string.Empty;

    if (paramValues != null && paramValues.Length > 0)
    {
        paramJson = string.Join(", ", paramValues.Select(v => JsonSerializer.Serialize(v, jsonSerializerOptions)));

        //New line characters do not currently work with EvaluateJavaScriptAsync. https://github.com/dotnet/maui/issues/11905
        //We don't need formatted JSON, and we don't want new line characters in string properties as that's likely to cause issues anyways.
        paramJson = paramJson.Replace("\r\n", " ").Replace("\n", " ");
    }

    return paramJson;
}

In my app I don't want null property values to be written. I suspect this may not be the case for everyone, thus it would be good to have a way for people to set both a default serializer and optionally pass in a custom serializer when calling InvokeJsMethodAsync

Eilon commented 1 month ago

Thank you @rbrundritt ! I've been inspired by your code suggestions and I think I have something working that will be part of my PR https://github.com/dotnet/maui/pull/23769 for the official HybridWebView version. I need to test a bunch more things but I do have an end-to-end scenario working with actual async JavaScript code!

Eilon commented 1 month ago

Alright I pushed a major update to https://github.com/dotnet/maui/pull/23769 , in particular this last commit: https://github.com/dotnet/maui/pull/23769/commits/02eb87638a60dd0da24b88dc0121146d6831c515

I still have cleanup to do, but I've tested it on Windows and Android quite a bit and it seems to work! I'll test iOS/Mac later.

@rbrundritt thank you so much for all your help on this!