Skip to content

Commit 9f0c4b7

Browse files
Copilotmattleibow
andauthored
HybridWebView Bi-Directional Exception Handling (#31521)
* Initial plan * Implement HybridWebView C# exception bubbling to JavaScript - Extended DotNetInvokeResult to include error information fields - Modified InvokeDotNetAsync to return structured error responses instead of null - Updated TypeScript/JavaScript to detect and throw errors from C# responses - Added comprehensive tests for exception handling scenarios - Created test HTML page for exception testing Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> * Add demo exception handling to HybridWebView sample - Added ThrowException and ThrowExceptionAsync methods to demonstrate new functionality - Added JavaScript functions to test and showcase exception catching - Added UI buttons to trigger exception handling tests - Updated sample to show both error and success scenarios Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> * Add JavaScript exception handling tests for HybridWebView Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> * Handle reflection exceptions * AI was right! --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> Co-authored-by: Matthew Leibowitz <mattleibow@live.com>
1 parent 90594ca commit 9f0c4b7

File tree

8 files changed

+396
-3
lines changed

8 files changed

+396
-3
lines changed

src/Controls/samples/Controls.Sample/Pages/Controls/HybridWebViewPage.xaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@
3535
Text="Invoke Async JS"
3636
Clicked="InvokeAsyncJSMethodButton_Clicked" />
3737

38+
<Button
39+
Margin="10"
40+
Text="Test JS Exception"
41+
Clicked="InvokeJSExceptionButton_Clicked" />
42+
43+
<Button
44+
Margin="10"
45+
Text="Test JS Async Exception"
46+
Clicked="InvokeJSAsyncExceptionButton_Clicked" />
47+
3848
</VerticalStackLayout>
3949

4050
<HybridWebView

src/Controls/samples/Controls.Sample/Pages/Controls/HybridWebViewPage.xaml.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,52 @@ private void hwv_RawMessageReceived(object sender, HybridWebViewRawMessageReceiv
7474
Dispatcher.Dispatch(() => statusText.Text += Environment.NewLine + e.Message);
7575
}
7676

77+
private async void InvokeJSExceptionButton_Clicked(object sender, EventArgs e)
78+
{
79+
var statusResult = "";
80+
81+
try
82+
{
83+
statusResult += Environment.NewLine + "Calling JavaScript function that throws exception...";
84+
var result = await hwv.InvokeJavaScriptAsync<string>(
85+
"ThrowJavaScriptError",
86+
SampleInvokeJsContext.Default.String,
87+
[],
88+
[]);
89+
90+
statusResult += Environment.NewLine + "JavaScript function unexpectedly succeeded with result: " + result;
91+
}
92+
catch (Exception ex)
93+
{
94+
statusResult += Environment.NewLine + $"Caught JavaScript exception in C#: {ex.GetType().Name} - {ex.Message}";
95+
}
96+
97+
Dispatcher.Dispatch(() => statusText.Text += statusResult);
98+
}
99+
100+
private async void InvokeJSAsyncExceptionButton_Clicked(object sender, EventArgs e)
101+
{
102+
var statusResult = "";
103+
104+
try
105+
{
106+
statusResult += Environment.NewLine + "Calling async JavaScript function that throws exception...";
107+
var result = await hwv.InvokeJavaScriptAsync<string>(
108+
"ThrowJavaScriptErrorAsync",
109+
SampleInvokeJsContext.Default.String,
110+
[],
111+
[]);
112+
113+
statusResult += Environment.NewLine + "Async JavaScript function unexpectedly succeeded with result: " + result;
114+
}
115+
catch (Exception ex)
116+
{
117+
statusResult += Environment.NewLine + $"Caught async JavaScript exception in C#: {ex.GetType().Name} - {ex.Message}";
118+
}
119+
120+
Dispatcher.Dispatch(() => statusText.Text += statusResult);
121+
}
122+
77123
public class ComputationResult
78124
{
79125
public double result { get; set; }
@@ -153,6 +199,21 @@ public async Task<SyncReturn> DoAsyncWorkParamsReturn(int i, string s)
153199
Value = i,
154200
};
155201
}
202+
203+
// Demo method that throws an exception to showcase error handling
204+
public void ThrowException()
205+
{
206+
Debug.WriteLine("ThrowException called - about to throw");
207+
throw new InvalidOperationException("This is a test exception thrown from C# code!");
208+
}
209+
210+
// Demo async method that throws an exception
211+
public async Task<string> ThrowExceptionAsync()
212+
{
213+
Debug.WriteLine("ThrowExceptionAsync called - about to throw");
214+
await Task.Delay(100);
215+
throw new ArgumentException("This is an async test exception thrown from C# code!");
216+
}
156217
}
157218

158219
public class SyncReturn

src/Controls/samples/Controls.Sample/Resources/Raw/HybridSamplePage/index.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,44 @@
9393
LogMessage("Invoked DoAsyncWorkParamsReturn, return value: message=" + retValue.Message + ", value=" + retValue.Value);
9494
}
9595

96+
// Demo functions to test exception handling
97+
async function InvokeThrowException() {
98+
LogMessage("Invoking ThrowException");
99+
try {
100+
await window.HybridWebView.InvokeDotNet('ThrowException');
101+
LogMessage("ThrowException unexpectedly succeeded!");
102+
} catch (ex) {
103+
LogMessage("Caught exception from ThrowException: " + ex.message);
104+
LogMessage("Exception type: " + (ex.dotNetErrorType || "Unknown"));
105+
if (ex.dotNetStackTrace) {
106+
LogMessage("Stack trace: " + ex.dotNetStackTrace.substring(0, 200) + "...");
107+
}
108+
}
109+
}
110+
111+
async function InvokeThrowExceptionAsync() {
112+
LogMessage("Invoking ThrowExceptionAsync");
113+
try {
114+
const result = await window.HybridWebView.InvokeDotNet('ThrowExceptionAsync');
115+
LogMessage("ThrowExceptionAsync unexpectedly succeeded with result: " + result);
116+
} catch (ex) {
117+
LogMessage("Caught async exception from ThrowExceptionAsync: " + ex.message);
118+
LogMessage("Exception type: " + (ex.dotNetErrorType || "Unknown"));
119+
}
120+
}
121+
122+
// JavaScript functions that throw exceptions (for testing C# exception handling of JS calls)
123+
function ThrowJavaScriptError() {
124+
LogMessage("ThrowJavaScriptError called - about to throw");
125+
throw new Error("This is a test error thrown from JavaScript code!");
126+
}
127+
128+
async function ThrowJavaScriptErrorAsync() {
129+
LogMessage("ThrowJavaScriptErrorAsync called - about to throw");
130+
await new Promise(resolve => setTimeout(resolve, 100));
131+
throw new Error("This is an async test error thrown from JavaScript code!");
132+
}
133+
96134
</script>
97135
</head>
98136
<body>
@@ -114,6 +152,16 @@
114152
<button onclick="InvokeDoAsyncWorkReturn()">Call C# async method (no params) and get simple return value</button>
115153
<button onclick="InvokeDoAsyncWorkParamsReturn()">Call C# async method (params) and get complex return value</button>
116154
</div>
155+
<div>
156+
<strong>Exception Handling Tests:</strong>
157+
<button onclick="InvokeThrowException()">Test C# Exception Handling</button>
158+
<button onclick="InvokeThrowExceptionAsync()">Test C# Async Exception Handling</button>
159+
</div>
160+
<div>
161+
<strong>JavaScript Exception Tests (called from C#):</strong>
162+
<button onclick="ThrowJavaScriptError()">Test JS Exception</button>
163+
<button onclick="ThrowJavaScriptErrorAsync()">Test JS Async Exception</button>
164+
</div>
117165
<div>
118166
Log: <textarea readonly id="messageLog" style="width: 80%; height: 10em;"></textarea>
119167
</div>
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#nullable enable
2+
using System;
3+
using System.Threading.Tasks;
4+
using Xunit;
5+
6+
namespace Microsoft.Maui.DeviceTests;
7+
8+
[Category(TestCategory.HybridWebView)]
9+
#if WINDOWS
10+
[Collection(WebViewsCollection)]
11+
#endif
12+
public partial class HybridWebViewTests_ExceptionHandling : HybridWebViewTestsBase
13+
{
14+
[Fact]
15+
public Task CSharpMethodThatThrowsException_ShouldPropagateToJavaScript() =>
16+
RunTest("exception-tests.html", async (hybridWebView) =>
17+
{
18+
var invokeJavaScriptTarget = new TestExceptionMethods();
19+
hybridWebView.SetInvokeJavaScriptTarget(invokeJavaScriptTarget);
20+
21+
// Tell JavaScript to invoke the method that throws an exception
22+
hybridWebView.SendRawMessage("ThrowException");
23+
24+
// Wait for JavaScript to handle the exception
25+
await WebViewHelpers.WaitForHtmlStatusSet(hybridWebView);
26+
27+
// Check that JavaScript caught the exception
28+
var caughtError = await hybridWebView.EvaluateJavaScriptAsync("GetLastError()");
29+
Assert.Equal("Test exception message", caughtError);
30+
31+
// Check that the method was called before throwing
32+
Assert.Equal("ThrowException", invokeJavaScriptTarget.LastMethodCalled);
33+
});
34+
35+
[Fact]
36+
public Task CSharpAsyncMethodThatThrowsException_ShouldPropagateToJavaScript() =>
37+
RunTest("exception-tests.html", async (hybridWebView) =>
38+
{
39+
var invokeJavaScriptTarget = new TestExceptionMethods();
40+
hybridWebView.SetInvokeJavaScriptTarget(invokeJavaScriptTarget);
41+
42+
// Tell JavaScript to invoke the async method that throws an exception
43+
hybridWebView.SendRawMessage("ThrowExceptionAsync");
44+
45+
// Wait for JavaScript to handle the exception
46+
await WebViewHelpers.WaitForHtmlStatusSet(hybridWebView);
47+
48+
// Check that JavaScript caught the async exception
49+
var caughtError = await hybridWebView.EvaluateJavaScriptAsync("GetLastError()");
50+
Assert.Equal("Async test exception", caughtError);
51+
52+
// Check that the method was called before throwing
53+
Assert.Equal("ThrowExceptionAsync", invokeJavaScriptTarget.LastMethodCalled);
54+
});
55+
56+
[Fact]
57+
public Task CSharpMethodThatThrowsCustomException_ShouldIncludeExceptionDetails() =>
58+
RunTest("exception-tests.html", async (hybridWebView) =>
59+
{
60+
var invokeJavaScriptTarget = new TestExceptionMethods();
61+
hybridWebView.SetInvokeJavaScriptTarget(invokeJavaScriptTarget);
62+
63+
// Tell JavaScript to invoke the method that throws a custom exception
64+
hybridWebView.SendRawMessage("ThrowCustomException");
65+
66+
// Wait for JavaScript to handle the exception
67+
await WebViewHelpers.WaitForHtmlStatusSet(hybridWebView);
68+
69+
// Check that JavaScript caught the exception with custom details
70+
var errorType = await hybridWebView.EvaluateJavaScriptAsync("GetLastErrorType()");
71+
var errorMessage = await hybridWebView.EvaluateJavaScriptAsync("GetLastErrorMessage()");
72+
73+
Assert.Equal("ArgumentException", errorType);
74+
Assert.Equal("Custom argument exception", errorMessage);
75+
});
76+
77+
[Fact]
78+
public Task CSharpMethodThatSucceeds_ShouldStillWorkNormally() =>
79+
RunTest("exception-tests.html", async (hybridWebView) =>
80+
{
81+
var invokeJavaScriptTarget = new TestExceptionMethods();
82+
hybridWebView.SetInvokeJavaScriptTarget(invokeJavaScriptTarget);
83+
84+
// Tell JavaScript to invoke a normal method that doesn't throw
85+
hybridWebView.SendRawMessage("SuccessMethod");
86+
87+
// Wait for JavaScript to complete
88+
await WebViewHelpers.WaitForHtmlStatusSet(hybridWebView);
89+
90+
// Check that JavaScript got the normal result
91+
var result = await hybridWebView.EvaluateJavaScriptAsync("GetLastResult()");
92+
Assert.Contains("Success!", result, StringComparison.Ordinal);
93+
94+
// Check that no error was thrown
95+
var hasError = await hybridWebView.EvaluateJavaScriptAsync("HasError()");
96+
Assert.Contains("false", hasError, StringComparison.Ordinal);
97+
});
98+
99+
private class TestExceptionMethods
100+
{
101+
public string? LastMethodCalled { get; private set; }
102+
103+
public void ThrowException()
104+
{
105+
LastMethodCalled = nameof(ThrowException);
106+
throw new InvalidOperationException("Test exception message");
107+
}
108+
109+
public void ThrowCustomException()
110+
{
111+
LastMethodCalled = nameof(ThrowCustomException);
112+
throw new ArgumentException("Custom argument exception");
113+
}
114+
115+
public async Task ThrowExceptionAsync()
116+
{
117+
LastMethodCalled = nameof(ThrowExceptionAsync);
118+
await Task.Delay(10); // Make it actually async
119+
throw new InvalidOperationException("Async test exception");
120+
}
121+
122+
public string SuccessMethod()
123+
{
124+
LastMethodCalled = nameof(SuccessMethod);
125+
return "Success!";
126+
}
127+
}
128+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<!DOCTYPE html>
2+
3+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
4+
<head>
5+
<meta charset="utf-8" />
6+
<title></title>
7+
<link rel="icon" href="data:,">
8+
<script src="_framework/hybridwebview.js"></script>
9+
<script>
10+
window.addEventListener(
11+
"HybridWebViewMessageReceived",
12+
async function (e) {
13+
var methodToInvoke = e.detail.message;
14+
var result = null;
15+
var error = null;
16+
var hasError = false;
17+
18+
try {
19+
// Invoke the requested method
20+
result = await window.HybridWebView.InvokeDotNet(methodToInvoke);
21+
} catch (ex) {
22+
hasError = true;
23+
error = {
24+
message: ex.message,
25+
type: ex.dotNetErrorType || ex.name,
26+
stackTrace: ex.dotNetStackTrace || ex.stack
27+
};
28+
console.error('Caught exception from .NET method:', ex);
29+
}
30+
31+
// Store the results so they can be checked in the test
32+
lastResult = result;
33+
lastError = error;
34+
lastHasError = hasError;
35+
SetStatusDone();
36+
});
37+
38+
var lastResult = null;
39+
var lastError = null;
40+
var lastHasError = false;
41+
42+
function GetLastResult() {
43+
return lastResult === null ? null : JSON.stringify(lastResult);
44+
}
45+
46+
function GetLastError() {
47+
return lastError ? lastError.message : null;
48+
}
49+
50+
function GetLastErrorType() {
51+
return lastError ? lastError.type : null;
52+
}
53+
54+
function GetLastErrorMessage() {
55+
return lastError ? lastError.message : null;
56+
}
57+
58+
function HasError() {
59+
return lastHasError.toString();
60+
}
61+
62+
function SetStatusDone() {
63+
document.getElementById('status').innerHTML = "Done!";
64+
}
65+
66+
</script>
67+
</head>
68+
<body>
69+
<div>
70+
Hybrid exception test!
71+
</div>
72+
<div id="htmlLoaded"></div>
73+
<div id="status"></div>
74+
</body>
75+
</html>

src/Core/src/Handlers/HybridWebView/HybridWebView.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,17 @@
154154
if (!response) {
155155
return null;
156156
}
157+
// Check if the response indicates an error
158+
if (response.IsError) {
159+
const error = new Error(response.ErrorMessage || 'Unknown error occurred in .NET method');
160+
if (response.ErrorType) {
161+
error.dotNetErrorType = response.ErrorType;
162+
}
163+
if (response.ErrorStackTrace) {
164+
error.dotNetStackTrace = response.ErrorStackTrace;
165+
}
166+
throw error;
167+
}
157168
if (response.IsJson) {
158169
return JSON.parse(response.Result);
159170
}

0 commit comments

Comments
 (0)