[BlazorWebView] Public API for external BlazorWebView backends - #37858
[BlazorWebView] Public API for external BlazorWebView backends#37858Redth wants to merge 3 commits into
Conversation
Third-party BlazorWebView handlers can already be registered through the public IBlazorWebViewHandler + UsePlatformHandler contract, but three pieces of the handler contract were still internal, forcing external backends to either skip functionality or duplicate MAUI source. Adds the smallest additive, backward-compatible seams for each: - BlazorWebViewInitializedEventArgs.NativeWebView: a platform-neutral object property so a handler on a target framework without a built-in MAUI backend can surface its native control. On target frameworks where the strongly typed WebView property exists, both are backed by the same value (WebView now reads through NativeWebView with an 'as' conversion, so it returns null rather than throwing on a mismatch). Existing app code reading e.WebView is unaffected. - RootComponent.AddToWebViewManagerAsync / RemoveFromWebViewManagerAsync are now public, so external handlers reuse MAUI's validation and ordering instead of reimplementing them. Both now null-check the manager argument. - BlazorWebViewStaticContentHotReload: a public seam over the internal StaticContentHotReloadManager exposing AttachToWebViewManagerIfEnabled and TryReplaceResponseContent, so external handlers can participate in MAUI Blazor static content hot reload with identical behavior. Adds src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests, an assembly that is deliberately not granted InternalsVisibleTo. It hosts a fake external handler and WebViewManager and proves all three seams are usable without privileged access or copied source, including a test that fails if InternalsVisibleTo is ever added. Also documents the contract in docs/design/BlazorWebViewExternalBackends.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37858Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37858" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR adds small, additive public API seams in the MAUI BlazorWebView stack to enable fully out-of-repo (third-party) platform backends without needing InternalsVisibleTo or copied MAUI source, and backs it with an “external handler” unit test project plus design documentation.
Changes:
- Add
BlazorWebViewInitializedEventArgs.NativeWebView(shared across MAUI/WPF/WinForms) and wire existing typedWebViewproperties to the same backing value. - Make
RootComponentadd/remove lifecycle methods public and introduce a public static-content hot reload seam (BlazorWebViewStaticContentHotReload). - Add
MauiBlazorWebView.ExternalHandler.UnitTests, wire it into solutions + Helix, and document the external-backend contract.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/TestRootComponents.cs | Adds minimal fake components used by external-backend tests. |
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/MauiBlazorWebView.ExternalHandler.UnitTests.csproj | New unit test project that intentionally has no internals access. |
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/FakeExternalWebViewManager.cs | Fake WebViewManager implementation for external-backend simulations. |
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/FakeExternalBlazorWebViewHandler.cs | Fake external IBlazorWebViewHandler exercising the public seams. |
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/ExternalStaticContentHotReloadTests.cs | Tests the new hot reload seam behavior and argument validation. |
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/ExternalRootComponentTests.cs | Tests public RootComponent lifecycle methods against a manager. |
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/ExternalNativeWebViewEventArgsTests.cs | Tests NativeWebView behavior and event wiring from an external handler. |
| src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/ExternalHandlerContractTests.cs | Guards the “no InternalsVisibleTo” premise and asserts key APIs are public. |
| src/BlazorWebView/src/Wpf/PublicAPI.Unshipped.txt | Public API baseline update for NativeWebView. |
| src/BlazorWebView/src/WindowsForms/PublicAPI.Unshipped.txt | Public API baseline update for NativeWebView. |
| src/BlazorWebView/src/SharedSource/BlazorWebViewInitializedEventArgs.cs | Adds NativeWebView and backs typed WebView properties with it. |
| src/BlazorWebView/src/Maui/RootComponent.cs | Makes add/remove lifecycle public and adds XML docs + null checks. |
| src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt | Public API baseline updates for new seams. |
| src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt | Public API baseline updates for new seams. |
| src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt | Public API baseline updates for new seams. |
| src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt | Public API baseline updates for new seams. |
| src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt | Public API baseline updates for new seams. |
| src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt | Public API baseline updates for new seams. |
| src/BlazorWebView/src/Maui/BlazorWebViewStaticContentHotReload.cs | Introduces the public static-content hot reload seam. |
| Microsoft.Maui.sln | Adds the new ExternalHandler unit test project to the main solution. |
| Microsoft.Maui-windows.slnf | Adds the new test project to the Windows solution filter. |
| Microsoft.Maui-vscode.sln | Adds the new ExternalHandler unit test project to the VSCode solution. |
| Microsoft.Maui-mac.slnf | Adds the new test project to the Mac solution filter. |
| Microsoft.Maui-dev.sln | Adds the new ExternalHandler unit test project to the dev solution. |
| eng/helix.proj | Includes the new unit test project in Helix xUnit runs. |
| docs/design/BlazorWebViewExternalBackends.md | Documents the supported external-backend contract and required seams. |
Suppressed comments (1)
src/BlazorWebView/src/Maui/RootComponent.cs:79
- Now that this is public API, the parameter name
webviewManager(lowercase ‘V’) is inconsistent withAddToWebViewManagerAsync(WebViewManager webViewManager)and will show up in IntelliSense. Consider renaming it towebViewManagerbefore shipping, and update the corresponding PublicAPI.*.txt baselines to match.
public Task RemoveFromWebViewManagerAsync(WebViewManager webviewManager)
| using System; | ||
| using System.Collections.Generic; | ||
| using System.IO; |
| { | ||
| ArgumentNullException.ThrowIfNull(webViewManager); | ||
|
|
||
| // As a characteristic of XAML,we can't rely on non-default constructors. So we have to |
This comment has been minimized.
This comment has been minimized.
Reworks the public surface added in the previous commit per design review. 1. Hot reload content is now a query, not a ref-mutation. The public API is TryGetUpdatedStaticContent(contentRootRelativePath, requestAbsoluteUri, out Stream? content, out string? contentType); the caller owns the response status, headers and disposal, and gets a fresh stream per call. The ref-mutating shape stays internal for the built-in managers. 2. The platform-neutral native view is now write-once and read-only. NativeWebView (public get/set) is replaced by PlatformWebView (public get only), supplied either through a new public BlazorWebViewInitializedEventArgs(object) ctor or, for the built-in handlers, through the existing internal typed WebView setter, which now throws if the value has already been set. An event subscriber can no longer change what later subscribers observe. 3. The property is scoped to the MAUI package. It is gated on WEBVIEW2_MAUI, so the WPF and WinForms packages keep only their existing typed WebView property and their PublicAPI files return to baseline. 4. Attach is observable and idempotent. TryAttachToWebViewManager returns null when hot reload is unsupported, otherwise the Task for the notifier registration. Repeat calls for the same manager return the first task instead of failing on the fixed root component selector. All four built-in MAUI handlers and their web view managers now route through the public seam, so the external contract is dogfooded. 5. The IDispatcher-to-Blazor-Dispatcher adapter, MauiDispatcher, is now public and argument-checked, so external backends stop copying it. Docs state explicitly that the static content response cache and its policy helpers stay internal and why. 6. RootComponent.RemoveFromWebViewManagerAsync's parameter is renamed webviewManager -> webViewManager to match AddToWebViewManagerAsync. External test assembly grows to 37 tests, still with no InternalsVisibleTo, covering read-only/write-once semantics, all-subscribers-see-the-same-instance, attach idempotence and per-manager independence, fresh-stream-per-call and caller ownership, public MauiDispatcher construction and dispatch, and reflection guards that no public ref-mutating API exists and that the lifecycle parameter is named correctly. Verified green with hot reload both disabled and enabled (DOTNET_MODIFIABLE_ASSEMBLIES=debug). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
AI Review Summary
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After FixGate Result:
|
| Rank | Candidate | Implementation | Validation | Assessment |
|---|---|---|---|---|
| 1 | pr-plus-reviewer |
Submitted design plus one consolidated reviewer patch | PASS: 24 passed, 0 failed, 4 skipped | Best balance of minimal API surface, observable async behavior, immutable event payload, consistent public naming, and credible external-consumer tests. |
| 2 | pr |
Raw submitted fix | Gate INCONCLUSIVE; no reliable pass/fail | Sound overall architecture, but expert review found six warnings and three suggestions. The most important unresolved concerns are fire-and-forget hot-reload registration, public post-construction mutation of shared event args, and weak/no-op test paths. |
| 3 (tie) | try-fix-1 |
Proposed framework-owned orchestration API | BLOCKED: no diff, no test run | Potentially centralizes sequencing, but it remained a design sketch and was never implemented or empirically evaluated. |
| 3 (tie) | try-fix-2 |
Proposed dependency-injected capability interfaces | BLOCKED: no diff, no test run | Offers narrower capability contracts but adds abstraction and likewise has no implementation or validation evidence. |
No candidate failed regression tests. Both try-fix candidates rank below implemented candidates because baseline safety blocked all edits and validation.
Why pr-plus-reviewer wins
- Hot-reload attachment now returns an awaitable task (
AttachToWebViewManagerIfEnabledAsync), so external handlers can ensure notifier registration completes before navigation and can observe duplicate-registration faults. NativeWebViewis init-only for external callers, preventing one event subscriber from changing the typed value observed by later subscribers while preserving built-in internal setters.- The public
RootComponentparameter spelling is consistent before API freeze, and misleading dispatcher guidance is removed. - External-handler startup awaits root-component registration; manager disposal and manager-visible registration checks eliminate false-positive/order-dependent tests.
- PublicAPI files and design documentation are aligned with the refined contracts.
Remaining uncertainty
The targeted Windows run compiled the candidate and passed every runnable test. Four hot-reload-enabled tests were explicitly skipped because this process was not launched with metadata-update support; CI's configured DOTNET_MODIFIABLE_ASSEMBLIES=debug path remains the evidence needed for those branches. The original trusted gate was infrastructure-inconclusive and is not treated as a candidate failure.
Because the winning changes are not present in the submitted PR HEAD, the PR should be updated with pr-plus-reviewer/reviewer.patch before approval.
🧭 Next Steps — reviewer changes required
The reviewer-enhanced candidate identified changes that are not yet in the submitted PR.
Why: The reviewer-refined PR preserves the submitted architecture while fixing observable async registration, event-argument mutability, public naming, and test-lifetime/coverage issues. Its targeted test project passed with 24 passed, 0 failed, and 4 metadata-update tests explicitly skipped.
Address the actionable findings in this review before merging.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 37 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
src/BlazorWebView/src/Maui/RootComponent.cs:49
- Minor typo in the comment: missing space after the comma (
XAML,we). This reads oddly in docs/comments and is easy to fix while touching the method.
// As a characteristic of XAML,we can't rely on non-default constructors. So we have to
| /// Stands in for the <see cref="WebViewManager"/> an out-of-repo BlazorWebView backend would write. | ||
| /// It only uses public API, including MAUI's public <see cref="MauiBlazorDispatcher"/> adapter rather | ||
| /// than a copied <see cref="AspNetCore.Components.Dispatcher"/> implementation. |
| /// Stands in for the MAUI <see cref="IDispatcher"/> an external backend resolves from its services and | ||
| /// hands to <see cref="AspNetCore.Components.WebView.Maui.MauiDispatcher"/>. It runs everything inline. | ||
| /// </summary> |
… shape Follow-up to the API design review. Detach lifecycle. BlazorWebViewStaticContentHotReload gains TryDetachFromWebViewManager(WebViewManager), returning null when nothing was attached and otherwise a Task that completes once the notifier root component has been removed. Removal is sequenced after the in-flight attach, so it cannot race into "there is no root component with selector 'body::after'". Detaching is idempotent and clears the weak attach entry, so a handler that is disconnected and later reconnected can attach again -- previously the second attach silently replayed the first task and the notifier was never re-registered. All four in-box MAUI handlers now detach in their disconnect path before disposing the manager. Duplicated shape removed. The internal MAUI-side TryReplaceResponseContent wrapper is deleted. The Android, iOS, Tizen and Windows web view managers now call the public TryGetUpdatedStaticContent directly and apply the result to their own response state, so the in-box code path is literally the public seam rather than a parallel convenience over it. The remaining ref-based helper on StaticContentHotReloadManager is now reachable only from the WEBVIEW2_WINFORMS/WEBVIEW2_WPF branch of WebView2WebViewManager, which cannot reference the MAUI-only type. In-box dogfooding is now 12 call sites: attach, detach and content lookup on each of the four platforms. Tests grow to 43. New coverage: detach removes the notifier, detach returns null when nothing was attached, detach is idempotent, attach works again after detach, detach rejects null, and an external handler tears down through the seam. Two existing attach assertions were rewritten from reference identity to behavior -- AddRootComponentAsync returns Task.CompletedTask before a page is attached, so comparing task instances was vacuous; they now assert that exactly one registration exists by checking that exactly one removal succeeds. Verified with PublicApiType=Validate (local Debug defaults to Generate, which does not validate) on net11.0 and net11.0-android37.0; the API delta is byte-identical across all six TFM folders. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Ready for fresh review —
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 37 changed files in this pull request and generated 4 comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/BlazorWebView/src/SharedSource/BlazorWebViewInitializedEventArgs.cs:125
- SetPlatformWebView currently allows null, which means a handler can accidentally call the internal WebView setter with null and bypass the “already been set” guard (since _platformWebView stays null). That breaks the intended write-once semantics and diverges from the public constructor, which rejects null.
if (_platformWebView is not null)
{
throw new InvalidOperationException(
$"The platform web view for this {nameof(BlazorWebViewInitializedEventArgs)} has already been set.");
}
src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/FakeExternalWebViewManager.cs:14
- This XML doc cref is missing the leading "Microsoft." and will be unresolved (e.g. CS1574) when documentation generation is enabled.
src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/FakeExternalDispatcher.cs:8 - This XML doc cref is missing the leading "Microsoft." and will be unresolved (e.g. CS1574) when documentation generation is enabled.
| // Detach before disposal so a reconnected handler can attach the notifier again. | ||
| _ = BlazorWebViewStaticContentHotReload.TryDetachFromWebViewManager(_webviewManager); | ||
|
|
| // Detach before disposal so a reconnected handler can attach the notifier again. | ||
| _ = BlazorWebViewStaticContentHotReload.TryDetachFromWebViewManager(_webviewManager); | ||
|
|
| // Detach before disposal so a reconnected handler can attach the notifier again. | ||
| _ = BlazorWebViewStaticContentHotReload.TryDetachFromWebViewManager(_webviewManager); | ||
|
|
| if (_webviewManager != null) | ||
| { | ||
| // Detach before teardown so a reconnected handler can attach the notifier again. | ||
| _ = BlazorWebViewStaticContentHotReload.TryDetachFromWebViewManager(_webviewManager); | ||
| } |
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description of Change
BlazorWebViewalready supports third-party platform backends:IBlazorWebViewHandlerandIMauiBlazorWebViewBuilder.UsePlatformHandlerare public, andBlazorWebViewroutes through the interface rather than the concrete handler. But several parts of what a handler actually has to do were stillinternal, so an out-of-repo backend had to drop functionality or copy MAUI source.These gaps were found by building a real external backend against
Microsoft.AspNetCore.Components.WebView.Maui11.0.0-preview.7, and are reproduced in this PR by an in-repo test assembly that is deliberately not grantedInternalsVisibleTo.1. Platform-neutral native web view on
BlazorWebViewInitializedEventArgsBlazorWebViewInitializedEventArgs.WebViewis declared only under#if WINDOWS / ANDROID / IOS || MACCATALYST / TIZEN. On any other target framework the type has no members at all, so a third-party handler cannot populateBlazorWebViewInitializedwith anything meaningful.New, gated on
WEBVIEW2_MAUIso it is scoped to the MAUI package:The value is read-only and write-once: only the handler raising the event can supply it, either through the new constructor or — for the built-in handlers — through the existing typed
WebViewproperty, whose setter is stillinternaland now throws if the value has already been set. An event subscriber cannot change what later subscribers observe.On target frameworks where the typed
WebViewexists it reads through the same backing field:so
e.WebViewreturns exactly what it did before for Android/iOS/MacCatalyst/Windows, and a mismatched value returnsnullrather than throwing. The WPF and WinForms packages are unchanged — they keep only their existing typedWebViewproperty, and theirPublicAPI.Unshipped.txtfiles are back at baseline.2. Public
RootComponentlifecycleRootComponent.AddToWebViewManagerAsync/RemoveFromWebViewManagerAsyncwereinternal, so every third-party backend had to re-derive the "Selectoris required" / "ComponentTypeis required" validation and the add/remove ordering. Both are nowpublicwith docs and null-argument checks. The validation logic, messages and ordering are unchanged, and the parameter is namedwebViewManageron both.3. Static content hot reload seam
StaticContentHotReloadManagerstaysinternal— it is a[MetadataUpdateHandler]target with mutable static state, and itsref-mutating response method is not a shape we want third-party backends binding to. Instead, a public seam exposes a query and an observable attach:TryGetUpdatedStaticContentonly reports content. The caller owns its own status code, headers and disposal, and gets a fresh stream per call. Theref-mutating helper is retained internally for the built-in managers and is implemented on top of this public method.TryAttachToWebViewManagerreturnsnullwhen hot reload is unsupported and nothing was attached, otherwise theTaskfor the notifier registration, so callers can observe or await it. Attaching is idempotent perWebViewManagerinstance — a repeat call returns the first task instead of failing on the notifier's fixedbody::afterselector.TryDetachFromWebViewManagercompletes the lifecycle. It returnsnullwhen nothing was attached, otherwise theTaskfor removing the notifier, sequenced after any in-flight attach so it cannot race into "there is no root component with selectorbody::after". It is idempotent and clears the weak attach entry, so a handler that is disconnected and later reconnected can attach again — previously a second attach silently replayed the first task and the notifier was never re-registered.TryGetUpdatedStaticContentand apply the result to their own response state, so the shipping code path is the public API rather than a parallel one. The remaining internal ref-based helper is reachable only from theWEBVIEW2_WINFORMS || WEBVIEW2_WPFbranch ofWebView2WebViewManager, which cannot reference the MAUI-only type.4. Public dispatcher adapter
MauiDispatcher, theIDispatcher→ BlazorDispatcheradapter the built-in handlers use, wasinternal, so external backends copied it. It is nowpublic sealedwith docs and an argument check:Behavior preserved
WebView = _webviewin the existing object initializers still works.~...BlazorWebViewInitializedEventArgs.WebView.getPublicAPI entries are unchanged — the property shape (public get, internal set) is the same.MetadataUpdateHandlerregistration and theMetadataUpdater.IsSupportedgate are unchanged; only the shape of the API around them is new, plus attach idempotence which previously would have thrown.Intentionally still internal
The static content response cache and its policy helpers (
StaticContentResponseCache,StaticContentResponseCachePolicy,StaticContentCacheControl,QueryStringHelper) stay internal so their storage shape, eviction, entry-size limits andCache-Control/Pragmaparsing remain free to change. This is now documented, along with the fact thatIBlazorWebView.StaticContentCacheControlProvideris public for apps to influence the emitted header.Tests
New
src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests(added toMicrosoft.Maui.sln,-dev.sln,-vscode.sln, both.slnffiles andeng/helix.proj).It stands in for a third-party backend package: no
InternalsVisibleTogrant, and a test that fails if one is ever added. It contains a fake externalIBlazorWebViewHandler, a fakeWebViewManagerbuilt on the publicMauiDispatcher, and a fakeIDispatcher.43 tests covering:
PlatformWebViewdefaults, constructor supply, null rejection, that the property has no setter, that it flows through the realBlazorWebViewInitializedevent with the correct sender, and that two subscribers observe the same instance.RootComponentadd/remove through the public API: registration, parameters, missingSelector, missingComponentType, duplicate selector, removing an unregistered selector, null manager, and add-in-collection-order from the handler.nullwhen nothing was attached, detach idempotence, re-attach after detach, and teardown through an external handler, plus unknown content, serving the_framework/static-content-hot-reload.jspayload withtext/javascript, a fresh independently-disposable stream per call, and argument validation.MauiDispatcherpublic construction, null rejection, and that it really dispatches through the suppliedIDispatcher.ref-mutating API exists on the seam and that the root component parameter is namedwebViewManager.Static content hot reload only activates when the runtime is started with
DOTNET_MODIFIABLE_ASSEMBLIES=debug.eng/helix.projalready sets that for every xUnit work item, so the enabled branch is the one CI runs; the suite was verified green locally in both modes.API surface was validated with
-p:PublicApiType=Validateonnet11.0andnet11.0-android37.0(local Debug builds default toGenerate, which does not validate), and the API delta is byte-identical across all six TFM folders.Also adds
docs/design/BlazorWebViewExternalBackends.mddescribing the full external-backend contract.Issues Fixed
None filed; found while building an external BlazorWebView backend against the .NET 11 packages.