Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[HybridWebView] Bubble up exceptions in JS into .NET #27129

Merged
merged 5 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,49 @@
}
},

"__InvokeJavaScript": function __InvokeJavaScript(taskId, methodName, args) {
if (methodName[Symbol.toStringTag] === 'AsyncFunction') {
// For async methods, we need to call the method and then trigger the callback when it's done
const asyncPromise = methodName(...args);
asyncPromise
.then(asyncResult => {
window.HybridWebView.__TriggerAsyncCallback(taskId, asyncResult);
})
.catch(error => console.error(error));
"__InvokeJavaScript": async function __InvokeJavaScript(taskId, methodName, args) {
try {
var result = null;
if (methodName[Symbol.toStringTag] === 'AsyncFunction') {
result = await methodName(...args);
} else {
result = methodName(...args);
}
window.HybridWebView.__TriggerAsyncCallback(taskId, result);
} catch (ex) {
console.error(ex);
window.HybridWebView.__TriggerAsyncFailedCallback(taskId, ex);
}
},

"__TriggerAsyncFailedCallback": function __TriggerAsyncCallback(taskId, error) {

if (!error) {
json = {
Message: "Unknown error",
StackTrace: Error().stack
};
} else if (error instanceof Error) {
json = {
Name: error.name,
Message: error.message,
StackTrace: error.stack
};
} else if (typeof (error) === 'string') {
json = {
Message: error,
StackTrace: Error().stack
};
} else {
// For sync methods, we can call the method and trigger the callback immediately
const syncResult = methodName(...args);
window.HybridWebView.__TriggerAsyncCallback(taskId, syncResult);
json = {
Message: JSON.stringify(error),
StackTrace: Error().stack
};
}

json = JSON.stringify(json);

window.HybridWebView.__SendMessageInternal('__InvokeJavaScriptFailed', taskId + '|' + json);
},

"__TriggerAsyncCallback": function __TriggerAsyncCallback(taskId, result) {
Expand Down
6 changes: 6 additions & 0 deletions src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ static MauiAppBuilder SetupDefaults(this MauiAppBuilder builder)
handlers.AddControlsHandlers();
});

// NOTE: not registered under NativeAOT or TrimMode=Full scenarios
if (RuntimeFeature.IsHybridWebViewSupported)
{
builder.Services.AddScoped<IHybridWebViewTaskManager>(_ => new HybridWebViewTaskManager());
}

#if WINDOWS
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IMauiInitializeService, MauiControlsInitializer>());
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Hosting;
Expand All @@ -24,6 +25,7 @@ void SetupBuilder()
});

builder.Services.AddHybridWebViewDeveloperTools();
builder.Services.AddScoped<IHybridWebViewTaskManager, HybridWebViewTaskManager>();
});
}

Expand Down Expand Up @@ -311,6 +313,106 @@ public Task InvokeDotNet(string methodName, string expectedReturnValue) =>
Assert.Equal(methodName, invokeJavaScriptTarget.LastMethodCalled);
});

[Theory]
[InlineData("")]
[InlineData("Async")]
public async Task InvokeJavaScriptMethodThatThrowsNumber(string type)
{
#if ANDROID
// NOTE: skip this test on older Android devices because it is not currently supported on these versions
if (!System.OperatingSystem.IsAndroidVersionAtLeast(24))
{
return;
}
#endif

var ex = await RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 1);
Assert.Equal("InvokeJavaScript threw an exception: 777.777", ex.Message);
Assert.Equal("777.777", ex.InnerException.Message);
Assert.Null(ex.InnerException.Data["JavaScriptErrorName"]);
Assert.NotNull(ex.InnerException.StackTrace);
}

[Theory]
[InlineData("")]
[InlineData("Async")]
public async Task InvokeJavaScriptMethodThatThrowsString(string type)
{
#if ANDROID
// NOTE: skip this test on older Android devices because it is not currently supported on these versions
if (!System.OperatingSystem.IsAndroidVersionAtLeast(24))
{
return;
}
#endif

var ex = await RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 2);
Assert.Equal("InvokeJavaScript threw an exception: String: 777.777", ex.Message);
Assert.Equal("String: 777.777", ex.InnerException.Message);
Assert.Null(ex.InnerException.Data["JavaScriptErrorName"]);
Assert.NotNull(ex.InnerException.StackTrace);
}

[Theory]
[InlineData("")]
[InlineData("Async")]
public async Task InvokeJavaScriptMethodThatThrowsError(string type)
{
#if ANDROID
// NOTE: skip this test on older Android devices because it is not currently supported on these versions
if (!System.OperatingSystem.IsAndroidVersionAtLeast(24))
{
return;
}
#endif

var ex = await RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 3);
Assert.Equal("InvokeJavaScript threw an exception: Generic Error: 777.777", ex.Message);
Assert.Equal("Generic Error: 777.777", ex.InnerException.Message);
Assert.Equal("Error", ex.InnerException.Data["JavaScriptErrorName"]);
Assert.NotNull(ex.InnerException.StackTrace);
}

[Theory]
[InlineData("")]
[InlineData("Async")]
public async Task InvokeJavaScriptMethodThatThrowsTypedNumber(string type)
{
#if ANDROID
// NOTE: skip this test on older Android devices because it is not currently supported on these versions
if (!System.OperatingSystem.IsAndroidVersionAtLeast(24))
{
return;
}
#endif

var ex = await RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 4);
Assert.Contains("undefined", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("undefined", ex.InnerException.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal("TypeError", ex.InnerException.Data["JavaScriptErrorName"]);
Assert.NotNull(ex.InnerException.StackTrace);
}

async Task<Exception> RunExceptionTest(string method, int errorType)
{
Exception exception = null;

await RunTest(async (hybridWebView) =>
{
var x = 123.456m;
var y = 654.321m;

exception = await Assert.ThrowsAnyAsync<Exception>(() =>
hybridWebView.InvokeJavaScriptAsync<decimal>(
method,
HybridWebViewTestContext.Default.Decimal,
[x, y, errorType],
[HybridWebViewTestContext.Default.Decimal, HybridWebViewTestContext.Default.Decimal, HybridWebViewTestContext.Default.Int32]));
});

return exception;
}

Task RunTest(Func<HybridWebView, Task> test) =>
RunTest(null, test);

Expand Down Expand Up @@ -460,6 +562,7 @@ public class ComputationResult

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(ComputationResult))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(decimal))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(int))]
Expand Down
8 changes: 7 additions & 1 deletion src/Controls/tests/DeviceTests/MauiProgram.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Hosting;
Expand All @@ -9,6 +10,11 @@ namespace Microsoft.Maui.DeviceTests
{
public static class MauiProgram
{
static MauiProgram()
{
AppContext.SetSwitch("HybridWebView.InvokeJavaScriptThrowsExceptions", isEnabled: true);
}

#if ANDROID
public static Android.Content.Context CurrentContext => MauiProgramDefaults.DefaultContext;
#elif WINDOWS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@
<title></title>
<link rel="icon" href="data:,">
<script src="scripts/HybridWebView.js"></script>

<!-- test helper functions-->
<script>

// an async delay helper function
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

</script>

<script>

window.addEventListener(
"HybridWebViewMessageReceived",
function (e) {
Expand All @@ -20,6 +32,11 @@
return value;
}

</script>

<!-- test cases -->
<script>

// test method invoke with simple parameters and simple return value
// test evaluate javascript to invoke
function EvaluateMeWithParamsAndReturn(s1, s2) {
Expand Down Expand Up @@ -61,6 +78,36 @@
return jsonData;
}

function ThrowAnError(value, errorType) {
if (errorType === 1) { // throw number
throw value;
} else if (errorType === 2) { // throw string
throw `String: ${value}`;
} else if (errorType === 3) { // throw Error
throw new Error(`Generic Error: ${value}`);
} else if (errorType === 4) { // throw runtime Error
undefined.toString();
}

// there was an error throwing an error :-{
throw new Error(`Unknown error type: ${errorType}`);
}

// test method invoke with parameters that throws instead of returning correctly
function EvaluateMeWithParamsThatThrows(a, b, errorType) {
let sum = a + b;
ThrowAnError(sum, errorType);
}

// test async method invoke with parameters that throws instead of returning correctly
async function EvaluateMeWithParamsThatThrowsAsync(a, b, errorType) {
// actually do something sync
await delay(10);

let sum = a + b;
ThrowAnError(sum, errorType);
}

// test evaluate arbitrary javascript
window.TestKey = 'test_value';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,49 @@
}
},

"__InvokeJavaScript": function __InvokeJavaScript(taskId, methodName, args) {
if (methodName[Symbol.toStringTag] === 'AsyncFunction') {
// For async methods, we need to call the method and then trigger the callback when it's done
const asyncPromise = methodName(...args);
asyncPromise
.then(asyncResult => {
window.HybridWebView.__TriggerAsyncCallback(taskId, asyncResult);
})
.catch(error => console.error(error));
"__InvokeJavaScript": async function __InvokeJavaScript(taskId, methodName, args) {
try {
var result = null;
if (methodName[Symbol.toStringTag] === 'AsyncFunction') {
result = await methodName(...args);
} else {
result = methodName(...args);
}
window.HybridWebView.__TriggerAsyncCallback(taskId, result);
} catch (ex) {
console.error(ex);
window.HybridWebView.__TriggerAsyncFailedCallback(taskId, ex);
}
},

"__TriggerAsyncFailedCallback": function __TriggerAsyncCallback(taskId, error) {

if (!error) {
json = {
Message: "Unknown error",
StackTrace: Error().stack
};
} else if (error instanceof Error) {
json = {
Name: error.name,
Message: error.message,
StackTrace: error.stack
};
} else if (typeof (error) === 'string') {
json = {
Message: error,
StackTrace: Error().stack
};
} else {
// For sync methods, we can call the method and trigger the callback immediately
const syncResult = methodName(...args);
window.HybridWebView.__TriggerAsyncCallback(taskId, syncResult);
json = {
Message: JSON.stringify(error),
StackTrace: Error().stack
};
}

json = JSON.stringify(json);

window.HybridWebView.__SendMessageInternal('__InvokeJavaScriptFailed', taskId + '|' + json);
},

"__TriggerAsyncCallback": function __TriggerAsyncCallback(taskId, result) {
Expand Down
Loading
Loading