From c9fba3865e2920e9a790bc43233fe8bcfd1eef43 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 21 Apr 2026 00:19:45 +0200 Subject: [PATCH 1/5] Simplify Android Blazor startup scripts and add tests Refactor RunBlazorStartupScripts into a single BlazorInitScript constant: - Replace 3 nested EvaluateJavascript callbacks with 1 script + 1 callback - Remove the unnecessary JS MessageChannel relay (was an extra hop for native-to-JS messages) and dispatch directly from window.message - Use a single captured native port (window.__nativePort) for JS-to-native - Call Blazor.start() inside the capturePort handler so the port is ready before Blazor sends any messages (fixes a latent ordering issue) - Split flags: __BlazorStarting (re-entry guard) and __BlazorStarted (readiness signal used by device test helpers) - Only process messages with null event.source (native PostWebMessage) - Add device tests verifying startup flags, port capture, window.external setup, idempotency, and message source filtering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Maui/Android/WebKitWebViewClient.cs | 117 ++++++++------- .../Elements/BlazorWebViewTests.Startup.cs | 137 ++++++++++++++++++ .../DeviceTests/WebViewHelpers.Shared.cs | 20 +++ 3 files changed, 214 insertions(+), 60 deletions(-) create mode 100644 src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs diff --git a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs index debf50d8b5f5..fbd860aa40b0 100644 --- a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs +++ b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs @@ -20,6 +20,56 @@ internal class WebKitWebViewClient : WebViewClient private static readonly Uri AppOriginUri = new(AppOrigin); + // Single startup script that: + // - Is idempotent (guarded by window.__BlazorStarting) so duplicate OnPageFinished calls are safe. + // - Sets up window.external.sendMessage/receiveMessage for the Blazor interop bridge. + // - Listens for the native 'capturePort' message that delivers the native WebMessagePort. + // - Calls Blazor.start() only after the native port is captured, ensuring window.external.sendMessage + // has a live port before Blazor sends any messages. + // - Sets window.__BlazorStarted after the Blazor.start() Promise resolves (used by test helpers as a readiness signal). + // - Dispatches native→JS messages (arriving via PostWebMessage) directly to window.external.__callback. + // - Validates message origin: only processes messages from the native PostWebMessage API + // (event.source === null), skipping messages from subframes or other JS contexts. + private const string BlazorInitScript = """ + (function () { + if (window.__BlazorStarting) { return 'false'; } + window.__BlazorStarting = true; + + window.external = window.external || {}; + window.external.sendMessage = function (message) { + if (window.__nativePort) { + window.__nativePort.postMessage(message); + } + }; + window.external.receiveMessage = function (callback) { + window.external.__callback = callback; + }; + + window.addEventListener('message', function (event) { + // Only process messages from the native PostWebMessage API. + // Native messages have event.source === null; messages from subframes + // or other JS contexts have event.source set to the sending window. + if (event.source !== null) { return; } + + if (event.data === 'capturePort') { + if (event.ports && event.ports[0] && !window.__nativePort) { + window.__nativePort = event.ports[0]; + Promise.resolve(Blazor.start()).then(function () { + window.__BlazorStarted = true; + }, function (err) { + console.error('Blazor.start() failed:', err); + window.__BlazorStarting = false; + }); + } + } else if (window.external.__callback) { + window.external.__callback(event.data); + } + }, false); + + return 'true'; + })(); + """; + private readonly BlazorWebViewHandler? _webViewHandler; public WebKitWebViewClient(BlazorWebViewHandler webViewHandler) @@ -155,69 +205,16 @@ private void RunBlazorStartupScripts(AWebView view) { _webViewHandler?.Logger.RunningBlazorStartupScripts(); - // Confirm Blazor hasn't already initialized - view.EvaluateJavascript(@" - (function() { return typeof(window.__BlazorStarted); })(); - ", new JavaScriptValueCallback(blazorStarted => + view.EvaluateJavascript(BlazorInitScript, new JavaScriptValueCallback(result => { - if (blazorStarted?.ToString() != "\"undefined\"") + // The init script returns 'true' if it performed first-time setup, or + // 'false' if it was a no-op (duplicate OnPageFinished). Only create the + // native MessageChannel when setup actually ran. + if (result?.ToString() == "\"true\"") { - // Blazor has already started, we can just abort startup process - return; - } - - // Set up JS ports - view.EvaluateJavascript(@" - - const channel = new MessageChannel(); - var nativeJsPortOne = channel.port1; - var nativeJsPortTwo = channel.port2; - window.addEventListener('message', function (event) { - if (event.data != 'capturePort') { - nativeJsPortOne.postMessage(event.data) - } - else if (event.data == 'capturePort') { - if (event.ports[0] != null) { - nativeJsPortTwo = event.ports[0] + _webViewHandler?.WebviewManager?.SetUpMessageChannel(); + _webViewHandler?.Logger.BlazorStartupScriptsFinished(); } - } - }, false); - - nativeJsPortOne.addEventListener('message', function (event) { - }, false); - - nativeJsPortTwo.addEventListener('message', function (event) { - // data from native code to JS - if (window.external.__callback) { - window.external.__callback(event.data); - } - }, false); - nativeJsPortOne.start(); - nativeJsPortTwo.start(); - - window.external.sendMessage = function (message) { - // data from JS to native code - nativeJsPortTwo.postMessage(message); - }; - - window.external.receiveMessage = function (callback) { - window.external.__callback = callback; - } - ", new JavaScriptValueCallback(_ => - { - // Set up Server ports - _webViewHandler?.WebviewManager?.SetUpMessageChannel(); - - // Start Blazor - view.EvaluateJavascript(@" - Blazor.start(); - window.__BlazorStarted = true; - ", new JavaScriptValueCallback(_ => - { - // Done; no more action required - _webViewHandler?.Logger.BlazorStartupScriptsFinished(); - })); - })); })); } diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs new file mode 100644 index 000000000000..d9df0ad53957 --- /dev/null +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.Maui; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.MauiBlazorWebView.DeviceTests.Components; +using Xunit; +#if ANDROID +using PlatformWebView = Android.Webkit.WebView; +#elif IOS || MACCATALYST +using PlatformWebView = WebKit.WKWebView; +#elif WINDOWS +using PlatformWebView = Microsoft.UI.Xaml.Controls.WebView2; +#else +using PlatformWebView = System.Object; +#endif + +namespace Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements; + +public partial class BlazorWebViewTests +{ + async Task RunBlazorStartupTest(Func test) + { + EnsureHandlerCreated(additionalCreationActions: appBuilder => + { + appBuilder.Services.AddMauiBlazorWebView(); + }); + + var bwv = new BlazorWebViewWithCustomFiles + { + HostPage = "wwwroot/index.html", + CustomFiles = new Dictionary + { + { "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent }, + }, + }; + bwv.RootComponents.Add(new RootComponent { ComponentType = typeof(NoOpComponent), Selector = "#app", }); + + await InvokeOnMainThreadAsync(async () => + { + var bwvHandler = CreateHandler(bwv); + var platformWebView = bwvHandler.PlatformView; + await WebViewHelpers.WaitForWebViewReady(platformWebView); + await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static"); + + await test(platformWebView); + }); + } + + [Fact] + public Task BlazorStartupSetsUpWindowExternal() => RunBlazorStartupTest(async platformWebView => + { + // window.external should have sendMessage and receiveMessage functions on all platforms. + // Use .toString() to normalize boolean results across platforms (iOS returns "1" for true). + var hasSendMessage = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "(typeof window.external.sendMessage === 'function').toString()"); + var hasReceiveMessage = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "(typeof window.external.receiveMessage === 'function').toString()"); + + Assert.True(hasSendMessage == "true" || hasSendMessage == "\"true\"", $"Expected sendMessage to be a function, got: {hasSendMessage}"); + Assert.True(hasReceiveMessage == "true" || hasReceiveMessage == "\"true\"", $"Expected receiveMessage to be a function, got: {hasReceiveMessage}"); + }); + +#if ANDROID + [Fact] + public Task BlazorStartupSetsStartingAndStartedFlags() => RunBlazorStartupTest(async platformWebView => + { + // After Blazor is fully started, both flags should be true + var startingValue = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__BlazorStarting === true"); + var startedValue = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__BlazorStarted === true"); + + Assert.Equal("true", startingValue); + Assert.Equal("true", startedValue); + }); + + [Fact] + public Task BlazorStartupCapturesNativePort() => RunBlazorStartupTest(async platformWebView => + { + // The native port should have been captured during startup + var hasNativePort = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__nativePort !== null && window.__nativePort !== undefined"); + + Assert.Equal("true", hasNativePort); + }); + + [Fact] + public Task BlazorStartupScriptIsIdempotent() => RunBlazorStartupTest(async platformWebView => + { + // Store the original native port reference + await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__originalNativePort = window.__nativePort"); + + // The __BlazorStarting guard should prevent re-initialization on duplicate OnPageFinished + var rerunResult = await WebViewHelpers.ExecuteScriptAsync(platformWebView, + "(function() { if (window.__BlazorStarting) { return 'blocked'; } return 'ran'; })()"); + + Assert.Equal("\"blocked\"", rerunResult); + + // Verify the native port is still the same reference + var portUnchanged = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__nativePort === window.__originalNativePort"); + Assert.Equal("true", portUnchanged); + }); + + [Fact] + public Task BlazorMessageDispatchOnlyProcessesNativeSourceMessages() => RunBlazorStartupTest(async platformWebView => + { + // Verify the message listener is working by checking that the native bridge + // dispatched messages during startup (Blazor is running, which means + // null-source native messages were processed correctly). + var blazorRunning = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.Blazor != null"); + Assert.Equal("true", blazorRunning); + + // Set up: a callback counter for native dispatch, and a separate test-only + // listener that gives us a positive signal when the message event fires. + await WebViewHelpers.ExecuteScriptAsync(platformWebView, @" + window.__nativeCallbackCount = 0; + window.__testMessageSeen = false; + window.external.receiveMessage(function(msg) { window.__nativeCallbackCount++; }); + window.addEventListener('message', function(event) { + if (event.data === 'test-from-js') { window.__testMessageSeen = true; } + }, false); + "); + + // Send a message from JS using window.postMessage — these have event.source + // set to the current window (non-null), so they go through a different path + // than native PostWebMessage messages (which have null source). + await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.postMessage('test-from-js', '*')"); + + // Wait for positive confirmation that the message event was processed. + // Our test listener sets __testMessageSeen when it sees the message, + // which proves the event loop has processed it and the production + // listener also had its chance to run. + await WebViewHelpers.WaitForCondition(platformWebView, "window.__testMessageSeen === true"); + + // Now we can safely check: the callback count should still be zero because + // only native-sourced messages (null source) are dispatched to the callback. + var count = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__nativeCallbackCount"); + Assert.Equal("0", count); + }); +#endif +} diff --git a/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs b/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs index 5be373be3f36..4851b1f00ddd 100644 --- a/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs +++ b/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs @@ -34,6 +34,26 @@ private static async Task Retry(Func> tryAction, Func + { + // Use .toString() to normalize booleans across platforms (iOS returns + // NSNumber "1" for true, Android returns "true", some platforms quote). + var result = await ExecuteScriptAsync(webView, $"({jsCondition}).toString()"); + return + result == "true" || + result == "\"true\"" || + result == "1" || + result == "\"1\""; + }, + timeoutInMS => + { + return Task.FromResult(new Exception($"Waited {timeoutInMS}ms but condition '{jsCondition}' never became true.")); + }); + } + /// /// Executes an async JavaScript function body and waits for the result to be stored in controlDiv. /// This method handles all the boilerplate for script injection and Promise avoidance. From ff86d5f2f299b5208eb8b2e7458ea18ce90d36c5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:22:45 +0200 Subject: [PATCH 2/5] Address review feedback for #35053 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Maui/Android/WebKitWebViewClient.cs | 34 +++++++++++++------ src/BlazorWebView/src/SharedSource/Log.cs | 3 ++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs index fbd860aa40b0..ddabe5bb9f93 100644 --- a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs +++ b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs @@ -28,8 +28,7 @@ internal class WebKitWebViewClient : WebViewClient // has a live port before Blazor sends any messages. // - Sets window.__BlazorStarted after the Blazor.start() Promise resolves (used by test helpers as a readiness signal). // - Dispatches native→JS messages (arriving via PostWebMessage) directly to window.external.__callback. - // - Validates message origin: only processes messages from the native PostWebMessage API - // (event.source === null), skipping messages from subframes or other JS contexts. + // - Validates message origin for dispatch messages, skipping messages from subframes or other JS contexts. private const string BlazorInitScript = """ (function () { if (window.__BlazorStarting) { return 'false'; } @@ -45,26 +44,39 @@ internal class WebKitWebViewClient : WebViewClient window.external.__callback = callback; }; - window.addEventListener('message', function (event) { - // Only process messages from the native PostWebMessage API. - // Native messages have event.source === null; messages from subframes - // or other JS contexts have event.source set to the sending window. - if (event.source !== null) { return; } + if (window.__BlazorMessageHandler) { + window.removeEventListener('message', window.__BlazorMessageHandler, false); + } + window.__BlazorMessageHandler = function (event) { if (event.data === 'capturePort') { if (event.ports && event.ports[0] && !window.__nativePort) { window.__nativePort = event.ports[0]; - Promise.resolve(Blazor.start()).then(function () { + Promise.resolve().then(function () { + return Blazor.start(); + }).then(function () { window.__BlazorStarted = true; }, function (err) { console.error('Blazor.start() failed:', err); + window.__nativePort = null; + window.__BlazorStarted = false; window.__BlazorStarting = false; }); } - } else if (window.external.__callback) { + return; + } + + // Only dispatch messages from the native PostWebMessage API. + // Native messages have event.source === null; messages from subframes + // or other JS contexts have event.source set to the sending window. + if (event.source !== null) { return; } + + if (window.external.__callback) { window.external.__callback(event.data); } - }, false); + }; + + window.addEventListener('message', window.__BlazorMessageHandler, false); return 'true'; })(); @@ -213,7 +225,7 @@ private void RunBlazorStartupScripts(AWebView view) if (result?.ToString() == "\"true\"") { _webViewHandler?.WebviewManager?.SetUpMessageChannel(); - _webViewHandler?.Logger.BlazorStartupScriptsFinished(); + _webViewHandler?.Logger.BlazorStartupScriptsSubmitted(); } })); } diff --git a/src/BlazorWebView/src/SharedSource/Log.cs b/src/BlazorWebView/src/SharedSource/Log.cs index 8865618cb3dd..8f6ec32daa73 100644 --- a/src/BlazorWebView/src/SharedSource/Log.cs +++ b/src/BlazorWebView/src/SharedSource/Log.cs @@ -56,6 +56,9 @@ internal static partial class Log [LoggerMessage(EventId = 16, Level = LogLevel.Debug, Message = "Blazor startup scripts finished.")] public static partial void BlazorStartupScriptsFinished(this ILogger logger); + [LoggerMessage(EventId = 40, Level = LogLevel.Debug, Message = "Blazor startup scripts submitted.")] + public static partial void BlazorStartupScriptsSubmitted(this ILogger logger); + [LoggerMessage(EventId = 17, Level = LogLevel.Debug, Message = "Creating WebKit WKWebView...")] public static partial void CreatingWebKitWKWebView(this ILogger logger); From 42d2bed6e3a221bd37fd386593ae406c5b11e64f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:49:37 +0200 Subject: [PATCH 3/5] Keep native port capture source-validated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Maui/Android/WebKitWebViewClient.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs index ddabe5bb9f93..384e16d99fed 100644 --- a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs +++ b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs @@ -28,7 +28,8 @@ internal class WebKitWebViewClient : WebViewClient // has a live port before Blazor sends any messages. // - Sets window.__BlazorStarted after the Blazor.start() Promise resolves (used by test helpers as a readiness signal). // - Dispatches native→JS messages (arriving via PostWebMessage) directly to window.external.__callback. - // - Validates message origin for dispatch messages, skipping messages from subframes or other JS contexts. + // - Validates message origin: only processes messages from the native PostWebMessage API + // (event.source === null), skipping messages from subframes or other JS contexts. private const string BlazorInitScript = """ (function () { if (window.__BlazorStarting) { return 'false'; } @@ -49,6 +50,11 @@ internal class WebKitWebViewClient : WebViewClient } window.__BlazorMessageHandler = function (event) { + // Only process messages from the native PostWebMessage API. + // Native messages have event.source === null; messages from subframes + // or other JS contexts have event.source set to the sending window. + if (event.source !== null) { return; } + if (event.data === 'capturePort') { if (event.ports && event.ports[0] && !window.__nativePort) { window.__nativePort = event.ports[0]; @@ -66,11 +72,6 @@ internal class WebKitWebViewClient : WebViewClient return; } - // Only dispatch messages from the native PostWebMessage API. - // Native messages have event.source === null; messages from subframes - // or other JS contexts have event.source set to the sending window. - if (event.source !== null) { return; } - if (window.external.__callback) { window.external.__callback(event.data); } From 99fa1cc6ac23c5e3a8a308b70c652a8a407c977a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:16:46 +0200 Subject: [PATCH 4/5] Verify Blazor startup idempotency through production path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Elements/BlazorWebViewTests.Startup.cs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs index d9df0ad53957..2e665375ade0 100644 --- a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.WebView.Maui; using Microsoft.Extensions.DependencyInjection; @@ -20,6 +21,11 @@ namespace Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements; public partial class BlazorWebViewTests { async Task RunBlazorStartupTest(Func test) + { + await RunBlazorStartupTest((_, platformWebView) => test(platformWebView)); + } + + async Task RunBlazorStartupTest(Func test) { EnsureHandlerCreated(additionalCreationActions: appBuilder => { @@ -43,7 +49,7 @@ await InvokeOnMainThreadAsync(async () => await WebViewHelpers.WaitForWebViewReady(platformWebView); await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static"); - await test(platformWebView); + await test(bwvHandler, platformWebView); }); } @@ -81,16 +87,19 @@ public Task BlazorStartupCapturesNativePort() => RunBlazorStartupTest(async plat }); [Fact] - public Task BlazorStartupScriptIsIdempotent() => RunBlazorStartupTest(async platformWebView => + public Task BlazorStartupScriptIsIdempotent() => RunBlazorStartupTest(async (bwvHandler, platformWebView) => { // Store the original native port reference await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__originalNativePort = window.__nativePort"); - // The __BlazorStarting guard should prevent re-initialization on duplicate OnPageFinished - var rerunResult = await WebViewHelpers.ExecuteScriptAsync(platformWebView, - "(function() { if (window.__BlazorStarting) { return 'blocked'; } return 'ran'; })()"); + var webViewClient = typeof(BlazorWebViewHandler) + .GetField("_webViewClient", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(bwvHandler) as global::Android.Webkit.WebViewClient; + + Assert.NotNull(webViewClient); - Assert.Equal("\"blocked\"", rerunResult); + // Simulate a duplicate OnPageFinished callback so the production startup path runs again. + webViewClient.OnPageFinished(platformWebView, platformWebView.Url); // Verify the native port is still the same reference var portUnchanged = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__nativePort === window.__originalNativePort"); From ae0890b52f4c8c39ec53892c81fb49f2b8d602eb Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:37:53 +0200 Subject: [PATCH 5/5] Fix Android Blazor startup rejection handling Preserve the captured native bridge when Blazor.start rejects after attaching, and add device coverage for live IPC and true startup idempotency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01ab9568-987a-42a3-8aba-0e7ba05c9cba --- .../src/Maui/Android/WebKitWebViewClient.cs | 4 +- .../Elements/BlazorWebViewTests.Startup.cs | 129 +++++++++++++++--- 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs index 384e16d99fed..8dd3385c700a 100644 --- a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs +++ b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs @@ -27,6 +27,7 @@ internal class WebKitWebViewClient : WebViewClient // - Calls Blazor.start() only after the native port is captured, ensuring window.external.sendMessage // has a live port before Blazor sends any messages. // - Sets window.__BlazorStarted after the Blazor.start() Promise resolves (used by test helpers as a readiness signal). + // - Logs startup failures without discarding the captured native port because Blazor may already be attached. // - Dispatches native→JS messages (arriving via PostWebMessage) directly to window.external.__callback. // - Validates message origin: only processes messages from the native PostWebMessage API // (event.source === null), skipping messages from subframes or other JS contexts. @@ -64,9 +65,6 @@ internal class WebKitWebViewClient : WebViewClient window.__BlazorStarted = true; }, function (err) { console.error('Blazor.start() failed:', err); - window.__nativePort = null; - window.__BlazorStarted = false; - window.__BlazorStarting = false; }); } return; diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs index 2e665375ade0..6d5a9aaa4d24 100644 --- a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.Startup.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.WebView.Maui; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Maui.MauiBlazorWebView.DeviceTests.Components; using Xunit; #if ANDROID @@ -20,16 +22,39 @@ namespace Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements; public partial class BlazorWebViewTests { - async Task RunBlazorStartupTest(Func test) + Task RunBlazorStartupTest( + Func test, + TestLoggerProvider? testLoggerProvider = null, + Type? componentType = null, + string? indexHtml = null, + Func? waitForReady = null) { - await RunBlazorStartupTest((_, platformWebView) => test(platformWebView)); + return RunBlazorStartupTest( + (_, platformWebView) => test(platformWebView), + testLoggerProvider, + componentType, + indexHtml, + waitForReady); } - async Task RunBlazorStartupTest(Func test) + async Task RunBlazorStartupTest( + Func test, + TestLoggerProvider? testLoggerProvider = null, + Type? componentType = null, + string? indexHtml = null, + Func? waitForReady = null) { EnsureHandlerCreated(additionalCreationActions: appBuilder => { appBuilder.Services.AddMauiBlazorWebView(); + if (testLoggerProvider is not null) + { + appBuilder.Services.AddLogging(logging => + { + logging.AddFilter("Microsoft.AspNetCore.Components.WebView", LogLevel.Trace); + logging.AddProvider(testLoggerProvider); + }); + } }); var bwv = new BlazorWebViewWithCustomFiles @@ -37,17 +62,24 @@ async Task RunBlazorStartupTest(Func { - { "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent }, + { "index.html", indexHtml ?? TestStaticFilesContents.DefaultMauiIndexHtmlContent }, }, }; - bwv.RootComponents.Add(new RootComponent { ComponentType = typeof(NoOpComponent), Selector = "#app", }); + bwv.RootComponents.Add(new RootComponent { ComponentType = componentType ?? typeof(NoOpComponent), Selector = "#app", }); await InvokeOnMainThreadAsync(async () => { var bwvHandler = CreateHandler(bwv); var platformWebView = bwvHandler.PlatformView; - await WebViewHelpers.WaitForWebViewReady(platformWebView); - await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static"); + if (waitForReady is null) + { + await WebViewHelpers.WaitForWebViewReady(platformWebView); + await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static"); + } + else + { + await waitForReady(platformWebView); + } await test(bwvHandler, platformWebView); }); @@ -87,24 +119,81 @@ public Task BlazorStartupCapturesNativePort() => RunBlazorStartupTest(async plat }); [Fact] - public Task BlazorStartupScriptIsIdempotent() => RunBlazorStartupTest(async (bwvHandler, platformWebView) => + public Task BlazorStartupScriptIsIdempotent() { - // Store the original native port reference - await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__originalNativePort = window.__nativePort"); + var testLoggerProvider = new TestLoggerProvider(); + return RunBlazorStartupTest(async (bwvHandler, platformWebView) => + { + var setupEventsBefore = testLoggerProvider.GetEvents().Count( + logEvent => logEvent.EventId.Id == 40 && logEvent.EventId.Name == "BlazorStartupScriptsSubmitted"); + Assert.Equal(1, setupEventsBefore); - var webViewClient = typeof(BlazorWebViewHandler) - .GetField("_webViewClient", BindingFlags.Instance | BindingFlags.NonPublic)! - .GetValue(bwvHandler) as global::Android.Webkit.WebViewClient; + // Store the original native port reference + await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__originalNativePort = window.__nativePort"); - Assert.NotNull(webViewClient); + var webViewClientField = typeof(BlazorWebViewHandler) + .GetField("_webViewClient", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(webViewClientField); - // Simulate a duplicate OnPageFinished callback so the production startup path runs again. - webViewClient.OnPageFinished(platformWebView, platformWebView.Url); + var webViewClient = webViewClientField.GetValue(bwvHandler) as global::Android.Webkit.WebViewClient; + Assert.NotNull(webViewClient); - // Verify the native port is still the same reference - var portUnchanged = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__nativePort === window.__originalNativePort"); - Assert.Equal("true", portUnchanged); - }); + // Simulate a duplicate OnPageFinished callback so the production startup path runs again. + webViewClient.OnPageFinished(platformWebView, platformWebView.Url); + await WebViewHelpers.ExecuteScriptAsync(platformWebView, "true"); + + // Verify the native port is still the same reference and no second channel setup was submitted. + var portUnchanged = await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__nativePort === window.__originalNativePort"); + Assert.Equal("true", portUnchanged); + + var setupEventsAfter = testLoggerProvider.GetEvents().Count( + logEvent => logEvent.EventId.Id == 40 && logEvent.EventId.Name == "BlazorStartupScriptsSubmitted"); + Assert.Equal(setupEventsBefore, setupEventsAfter); + }, testLoggerProvider); + } + + [Fact] + public Task BlazorStartupRejectionPreservesLiveBridge() + { + var rejectingIndexHtml = TestStaticFilesContents.DefaultMauiIndexHtmlContent.Replace( + "", + """ + + + """, + StringComparison.Ordinal); + + return RunBlazorStartupTest( + async platformWebView => + { + var nativePortPresent = await WebViewHelpers.ExecuteScriptAsync( + platformWebView, + "window.__nativePort !== null && window.__nativePort !== undefined"); + Assert.Equal("true", nativePortPresent); + + await WebViewHelpers.ExecuteScriptAsync( + platformWebView, + "document.getElementById('incrementButton').click()"); + await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "1"); + }, + componentType: typeof(TestComponent1), + indexHtml: rejectingIndexHtml, + waitForReady: async platformWebView => + { + await WebViewHelpers.WaitForCondition(platformWebView, "window.__wrappedBlazorStartRejected === true"); + await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "0"); + }); + } [Fact] public Task BlazorMessageDispatchOnlyProcessesNativeSourceMessages() => RunBlazorStartupTest(async platformWebView =>