Skip to content
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
128 changes: 68 additions & 60 deletions src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,67 @@ 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).
// - 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.
Comment thread
mattleibow marked this conversation as resolved.
// - 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 = """

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking housekeeping: maui-blazor.aotprofile.txt (lines 4738-4740) still lists WebKitWebViewClient/<>c__DisplayClass10_0:<RunBlazorStartupScripts>b__0, b__1, and b__2. Collapsing three lambdas into one renumbers the closure class, so those entries no longer resolve and this path loses AOT pre-compilation until the profile is regenerated. Small, but it sits on the Blazor cold-start path.

(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;
Comment thread
kubaflo marked this conversation as resolved.
};

if (window.__BlazorMessageHandler) {
window.removeEventListener('message', window.__BlazorMessageHandler, false);
}

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; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this above the capturePort branch (as 42d2bed did) is correct — flagging it here so it doesn't get moved back down in a later round.

Without a source check on capture, any script or iframe in the page can call window.postMessage('capturePort', '*', [ch.port2]) and win the !window.__nativePort race before the native port arrives, becoming the JS→.NET bridge and feeding forged BeginInvokeDotNet payloads into .NET. (main is strictly worse here — no source check and no already-captured guard, so a late fake port could replace a live one at any point.)

The source === null guarantee is also firmer than it looks from the outside. Chromium's content/browser/message_port_provider.cc has:

rfh->PostMessageEvent(std::nullopt, source_origin, target_origin, std::move(message));

The source frame token is hardcoded std::nullopt on the embedder path that WebView.postWebMessage uses — no branch, no version gate, and it's shared content/ code rather than anything WebView-specific.

Only nit: !== null is stricter than needed. if (event.source) { return; } also tolerates undefined at no cost.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Logic and Correctness (guard made more restrictive) / Cross-Platform Consistency — if (event.source !== null) { return; } narrows the guard relative to the code it replaces, and the previously-passing input it now rejects should be an explicit, acknowledged decision rather than a side effect of the rewrite.

Previously the window message listener relayed every non-'capturePort' message to the bridge (nativeJsPortOne.postMessage(event.data) → window.external.__callback), regardless of source. So any in-page script or subframe doing window.postMessage(payload, '*') could feed the Blazor callback. After this change only messages with event.source === null are dispatched. I agree the new behavior is the correct one — the old path was an undocumented injection surface into the .NET interop channel — but it is a silent breaking change for any app that (intentionally or accidentally) drove the bridge from page JS, and nothing in the PR documents it as such.

Two things worth confirming before merge:

  1. Acknowledge the break. State that in-page/subframe window.postMessage into __callback is intentionally no longer supported. It is not a listed breaking change anywhere in the diff.
  2. Validate the platform assumption. The whole bridge now hinges on Android WebView.postWebMessage delivering a MessageEvent with source === null. That holds for current Chromium WebView, but if any supported WebView version (API 23+ per the [SupportedOSPlatform("android23.0")] attribute on this class) populates source, native→JS dispatch dies completely and silently — no error, just a hung app. Since the assumption is load-bearing rather than incidental, a comment citing the WebView behaviour (or a defensive event.source !== null && event.source !== window form) would make the risk explicit. The PR's device tests do exercise the round trip, so the assumption is validated on the CI device image, but not across the min-API range.


if (event.data === 'capturePort') {
if (event.ports && event.ports[0] && !window.__nativePort) {
window.__nativePort = event.ports[0];
Promise.resolve().then(function () {
return Blazor.start();
}).then(function () {
window.__BlazorStarted = true;
}, function (err) {
console.error('Blazor.start() failed:', err);
});
}
Comment thread
kubaflo marked this conversation as resolved.
return;
}
Comment on lines +53 to +71

if (window.external.__callback) {
window.external.__callback(event.data);
}
};

window.addEventListener('message', window.__BlazorMessageHandler, false);

return 'true';
})();
""";

private readonly BlazorWebViewHandler? _webViewHandler;

public WebKitWebViewClient(BlazorWebViewHandler webViewHandler)
Expand Down Expand Up @@ -155,69 +216,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\"")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Logic and Correctness — If EvaluateJavascript delivers a null result (e.g. an unhandled JS exception inside the BlazorInitScript IIFE, or Android returning null for an undefined expression), result?.ToString() == "\"true\"" evaluates to false and silently falls through the else branch. This treats a script crash identically to a duplicate/no-op call: SetUpMessageChannel() is never called, __BlazorStarting is never reset, and there is no retry path — Blazor permanently fails to start until the next full-page navigation with no diagnostic emitted. Consider adding an explicit null-check with an error log, e.g.: if (result is null) { logger.LogError(...); return; } before the if/else on the string value.

{
// 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.BlazorStartupScriptsSubmitted();
}
Comment thread
kubaflo marked this conversation as resolved.
Comment on lines +224 to 228

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no else here, which turns every failure of this branch into a permanent wedge.

window.__BlazorStarting is set on line 36 — the first statement of the init script — but the native half only runs if this condition holds. It can fail three ways:

  • result is null (JS threw after line 36, or evaluation failed)
  • _webViewHandler is null — documented as a real state by the ctor comment on line 94: "called whenever the .NET proxy was disposed and it was recreated by Java... we have to check all methods below for null field references"
  • WebviewManager is null (handler disconnected mid-load)

In each case SetUpMessageChannel() never runs, capturePort is never posted, Blazor.start() is never called — and because JS still has __BlazorStarting === true, every subsequent OnPageFinished returns 'false' and no-ops. Blank page, no log, no recovery.

The old guard set __BlazorStarted at the end of the chain, so a later OnPageFinished could re-drive startup. This refactor genuinely fixes the concurrent-OnPageFinished double-init race that guard was chasing, so it'd be a shame to trade it for a new wedge.

This is also the right home for the reset that's currently in the JS rejection handler: on these paths Blazor.start() was never reached, so started is still false and a retry actually works.

Suggested change
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.BlazorStartupScriptsSubmitted();
}
if (result?.ToString() == "\"true\"")
{
_webViewHandler?.WebviewManager?.SetUpMessageChannel();
_webViewHandler?.Logger.BlazorStartupScriptsSubmitted();
}
else if (result?.ToString() != "\"false\"")
{
// Setup ran in JS but the native half did not, so clear the re-entry
// guard to let a later OnPageFinished retry. Blazor.start() was never
// reached on this path, so restarting is safe.
_webViewHandler?.Logger.BlazorStartupScriptsFailed();
view.EvaluateJavascript("window.__BlazorStarting = false;", null);
}

}
}, 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();
}));
}));
}));
}

Expand Down
3 changes: 3 additions & 0 deletions src/BlazorWebView/src/SharedSource/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.")]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two diagnosability notes now that this event is in play:

BlazorStartupScriptsFinished (EventId 16) is unreferenced repo-wide after this change — the only remaining mentions are this file and maui-blazor.aotprofile.txt. Either drop it or repurpose it.

More importantly, BlazorStartupScriptsSubmitted fires as soon as the init script returns — before capturePort, before Blazor.start() — so unlike EventId 16 it carries no signal about whether startup actually succeeded. Combined with the two failure modes I flagged in WebKitWebViewClient.cs, and with Blazor.start() rejection only reaching console.error (BlazorWebChromeClient doesn't override OnConsoleMessage), every new "Blazor never started" path is invisible to ILogger. Worth either posting a completion marker back over __nativePort, or overriding OnConsoleMessage, so this is diagnosable from app logs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[minor] Complexity Reduction / diagnosability — Adding BlazorStartupScriptsSubmitted leaves Android with no log for startup completion, and orphans the existing event.

After this change BlazorStartupScriptsFinished (EventId 16, line 57) has zero call sites anywhere in the repo — this was its only caller. Meanwhile the new EventId 40 fires when the init script has been submitted, before capturePort is delivered and before Blazor.start() resolves, so the two are not equivalent signals. Failures after that point — port never captured, or Blazor.start() rejecting — surface only as a console.error in the WebView (WebKitWebViewClient.cs line 67) and never reach ILogger. For a startup path whose whole failure mode is "nothing happens", losing the .NET-side completion signal makes field bug reports materially harder to diagnose.

Suggested fix: keep both signals — have the JS post a native message (or set a value the C# side polls once) on successful Blazor.start() resolution and log the existing BlazorStartupScriptsFinished from there; add an error log for the rejection branch. If a completion signal is genuinely not wanted, delete BlazorStartupScriptsFinished rather than leaving a dead LoggerMessage and a permanently-burned EventId.

public static partial void BlazorStartupScriptsSubmitted(this ILogger logger);
Comment on lines +59 to +60

[LoggerMessage(EventId = 17, Level = LogLevel.Debug, Message = "Creating WebKit WKWebView...")]
public static partial void CreatingWebKitWKWebView(this ILogger logger);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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
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
{
Task RunBlazorStartupTest(
Func<PlatformWebView, Task> test,
TestLoggerProvider? testLoggerProvider = null,
Type? componentType = null,
string? indexHtml = null,
Func<PlatformWebView, Task>? waitForReady = null)
{
return RunBlazorStartupTest(
(_, platformWebView) => test(platformWebView),
testLoggerProvider,
componentType,
indexHtml,
waitForReady);
}

async Task RunBlazorStartupTest(
Func<BlazorWebViewHandler, PlatformWebView, Task> test,
TestLoggerProvider? testLoggerProvider = null,
Type? componentType = null,
string? indexHtml = null,
Func<PlatformWebView, Task>? 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
{
HostPage = "wwwroot/index.html",
CustomFiles = new Dictionary<string, string>
{
{ "index.html", indexHtml ?? TestStaticFilesContents.DefaultMauiIndexHtmlContent },
},
};
bwv.RootComponents.Add(new RootComponent { ComponentType = componentType ?? typeof(NoOpComponent), Selector = "#app", });

await InvokeOnMainThreadAsync(async () =>
Comment thread
kubaflo marked this conversation as resolved.
{
var bwvHandler = CreateHandler<BlazorWebViewHandler>(bwv);
var platformWebView = bwvHandler.PlatformView;
if (waitForReady is null)
{
await WebViewHelpers.WaitForWebViewReady(platformWebView);
await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static");
}
else
{
await waitForReady(platformWebView);
}

await test(bwvHandler, 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()
{
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);

// Store the original native port reference
await WebViewHelpers.ExecuteScriptAsync(platformWebView, "window.__originalNativePort = window.__nativePort");

var webViewClientField = typeof(BlazorWebViewHandler)
.GetField("_webViewClient", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(webViewClientField);

var webViewClient = webViewClientField.GetValue(bwvHandler) as global::Android.Webkit.WebViewClient;
Assert.NotNull(webViewClient);

// Simulate a duplicate OnPageFinished callback so the production startup path runs again.
webViewClient.OnPageFinished(platformWebView, platformWebView.Url);
await WebViewHelpers.ExecuteScriptAsync(platformWebView, "true");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention / Async and Threading — await WebViewHelpers.ExecuteScriptAsync(platformWebView, "true") is being used as a synchronization barrier for work that it does not actually gate, which makes BlazorStartupScriptIsIdempotent able to pass for the wrong reason.

The assertion that discriminates a guard regression is Assert.Equal(setupEventsBefore, setupEventsAfter) (line 152): if the result?.ToString() == "\"true\"" check in RunBlazorStartupScripts were removed, the duplicate OnPageFinished would call SetUpMessageChannel() again and emit a second EventId 40. But that log entry is written from the IValueCallback.OnReceiveValue of the re-run init script, dispatched on the Android UI thread queue. Awaiting a different, later EvaluateJavascript("true") is not a documented ordering guarantee for that earlier callback, and the assertion is an equality-with-the-prior-value check — i.e. it is satisfied by the callback simply not having run yet. That is a false-negative (silently non-discriminating) test, not just a flaky one.

The companion assertion at line 147 does not discriminate either: window.__nativePort === window.__originalNativePort stays true even when a second channel is created, because the JS handler refuses to overwrite an existing port (!window.__nativePort, WebKitWebViewClient.cs line 60). So a regressed guard would leave the port identity unchanged and only the log count could catch it.

Suggested consolidated fix: replace the fake barrier with a positive wait plus a negative window. E.g. wait for a signal that provably follows the re-run (have the test assert on the value the init script returns, or use WebViewHelpers.WaitForCondition on a JS-visible counter incremented by the message handler on each capturePort receipt), and only then assert the EventId 40 count is still 1. A WaitForCondition-based poll that fails on reaching 2 within the retry window is far stronger than a single equality snapshot taken at an unsynchronized moment.


// 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(
"</body>",
"""
<script>
(function () {
const originalStart = Blazor.start.bind(Blazor);
Blazor.start = function (options) {
return originalStart(options).then(function (value) {
window.__wrappedBlazorStartRejected = true;
throw new Error('Deliberate post-start rejection');
});
};
})();
</script>
</body>
""",
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 =>
{
// 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);
Comment on lines +201 to +205

// 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++; });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but a trap for whoever extends this test: Android's receiveMessage stores a single window.external.__callback (lines 44-46), unlike iOS which pushes onto __receiveMessageCallbacks and Windows which uses addEventListener. So this call permanently unregisters Blazor's live IPC handler for the rest of the page's life.

Harmless today since the test ends immediately and each test builds a fresh handler, but chaining is nearly free:

Suggested change
window.external.receiveMessage(function(msg) { window.__nativeCallbackCount++; });
window.__priorCallback = window.external.__callback;
window.external.receiveMessage(function(msg) {
window.__nativeCallbackCount++;
if (window.__priorCallback) { window.__priorCallback(msg); }
});

Separately: this proves JS-sourced messages aren't dispatched, but nothing here proves native-sourced ones are — the window.Blazor != null check on line 115 passes as soon as the script loads, independent of the dispatch path. Asserting __nativeCallbackCount goes above zero after a real SendMessage() would close that half.

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
}
Loading
Loading