Add BlazorWebView.AppType to render the host page from an App.razor component - #36762
Add BlazorWebView.AppType to render the host page from an App.razor component#36762mattleibow wants to merge 11 commits into
Conversation
…omponent Blazor Hybrid boots from a static wwwroot/index.html, while a Blazor Web App boots from an App.razor component that renders the full HTML document. This adds an AppType property to BlazorWebView so a hybrid app can boot from the same kind of full-document component, aligning the two models. When AppType is set: - The component is statically rendered (via a StaticHtmlRenderer subclass) to produce the host document, so a physical HostPage file is not required. - An interactive component declared with a render mode (for example <Routes @rendermode="InteractiveAuto" />) is converted into a mount element plus a selector attach, so an explicit <RootComponent> is not required. - <HeadOutlet @rendermode="..." /> is attached at head::after for dynamic head. The rendered document is overlaid onto the platform file provider at the host page path, so all platforms (including Windows request interception) serve it uniformly with no per-platform changes. Adds a device test that boots a BlazorWebView from AppType and verifies the host document is served and the converted interactive component is live. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36762Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36762" |
Skill Validation Results
✅ Skill Validation Results —
|
Adds a device test that boots a BlazorWebView from AppType using a host document that declares <HeadOutlet> and a component with <PageTitle>. Verifies the document title updates from its static initial value to the interactive PageTitle value, proving dynamic head content works via the head::after attach. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
Blazor Web Apps resolve @assets and serve fingerprinted static assets via MapStaticAssets endpoint metadata. Hybrid apps have no server, so the static web assets endpoints manifest was never bundled and @assets did not resolve to fingerprinted URLs. This bundles the endpoints manifest (*.staticwebassets.endpoints.json) into the app under wwwroot/_maui/asset-manifest.json and loads it at runtime to: - Build a ResourceAssetCollection so @assets["logical"] resolves to the fingerprinted URL (via an override of the renderer's Assets property, matching the framework's own render pipeline), and - Map fingerprinted request routes back to their physical files so the web view serves the correct asset (the hybrid equivalent of MapStaticAssets). The manifest overlay is additive and hidden from the web view: apps that do not use fingerprinted assets, and the existing HostPage path, are unaffected. Adds device tests covering fingerprinted @assets resolution and serving, plus the with/without-assets and legacy HostPage paths. All BlazorWebView device tests pass on MacCatalyst. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
Move the bundled static web assets endpoints manifest out of the web root (_maui/blazor-asset-manifest.json) so it is never exposed to the web view, and read it from the app package via FileSystem.OpenAppPackageFileAsync instead of the served file provider. This removes the manifest-hiding logic from BlazorWebViewFileProvider. Parse the manifest with System.Text.Json source generation (JsonSerializerContext) instead of a JsonDocument DOM walk: explicitly AOT/trim-safe, lower-allocation, and consistent with existing BlazorWebView JSON usage. Verified on MacCatalyst device tests: 24 passed, 1 pre-existing skip; AppTypeResolvesFingerprintedAssetsViaAssets passes with the manifest read from outside wwwroot. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
…al-broccoli # Conflicts: # src/BlazorWebView/src/Maui/BlazorWebView.cs # src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt # src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt # src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt # src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt # src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt # src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt
The net11.0 base added UsePlatformHandler / IBlazorWebViewHandler and a test (BlazorWebViewUsesCustomHandlerOperations) that asserts CreateFileProvider returns the handler's own file provider by identity. The fingerprinting work wrapped the platform provider whenever a bundled asset manifest was present - even with no AppType - which broke that identity for every app with static web assets. Bind the whole feature (host-page rendering + @assets fingerprinting) to AppType: when AppType is null, return the platform provider unchanged, exactly preserving legacy behaviour. Fingerprinting only needs the wrapper on the AppType path, which is where the manifest's ResourceAssetCollection is injected into the renderer. MacCatalyst device tests: 45 passed, 1 pre-existing skip - including the base's custom-handler tests and the AppType/fingerprint tests together. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
The bundled fingerprint manifest was the SDK's raw static web assets endpoints
manifest, which embeds volatile per-file data (Last-Modified timestamps). On a
universal MacCatalyst build the x64 and arm64 legs produce byte-different
manifests, so the SDK's app-bundle merge fails:
error : Unable to merge the file 'Contents/Resources/_maui/blazor-asset-manifest.json',
it's different between the input app bundles.
This broke every Blazor Hybrid template build on macOS (BlazorTemplateTest and
SimpleTemplateTest with the maui-blazor template). Single-RID device tests never
hit the universal merge, so it wasn't caught locally.
Instead of bundling the raw manifest, generate a minimal manifest from the
@(StaticWebAssetEndpoint) items at build time containing only the fingerprinted
route and its logical label. That data is entirely content-derived (fingerprints
are content hashes, labels are logical names) with no timestamps, absolute paths,
or RIDs, and is emitted sorted - so it is byte-identical across architectures and
the universal merge succeeds. Verified: the x64 and arm64 manifests now have an
identical SHA-256.
Also simplifies the runtime: it now parses a tiny purpose-built file (via STJ
source-gen) instead of walking the large SDK endpoints manifest.
MacCatalyst device tests: 45 passed, 1 pre-existing skip, including
AppTypeResolvesFingerprintedAssetsViaAssets.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
This comment has been minimized.
This comment has been minimized.
TabbedPageTests.Windows.cs (added by #37755) calls AssertEventually(...) without 'using static Microsoft.Maui.DeviceTests.AssertHelpers;', breaking the net11.0 Windows build for every PR (CS0103). Every sibling partial of the class already has this using; the Windows partial was the only one missing it. This carries the same one-line fix as #37818 so this PR's own CI can compile the Windows leg and produce consumable packages. It self-heals once net11.0 (with #37818) is merged in — the identical line merges cleanly and drops from this PR's diff. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…ope MapAppType Addresses correctness concerns from PR review feedback: - HybridHostPageRenderer.Render now awaits the host component's QuiescenceTask before serializing to HTML, so asynchronously-initialized host content (e.g. OnInitializedAsync) and its render-mode registrations are not dropped from the generated document. Mirrors the framework's own static HTML rendering. - The static renderer is now disposed (on its dispatcher) after each host render instead of being leaked. - MapAppType early-returns when AppType is null, leaving the legacy HostPage startup path completely untouched. MacCatalyst device tests: 45 passed, 1 pre-existing skip (AppType host/mount, dynamic head, @assets fingerprinting, and legacy paths all green). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 13 findings
See inline comments for details.
| /// live document, so an explicit <see cref="RootComponents"/> entry is not required either. | ||
| /// </para> | ||
| /// </summary> | ||
| public Type? AppType |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
AppType is a public Type? with no [DynamicallyAccessedMembers], but it is passed to HybridHostPageRenderer.Render(..., [DynamicallyAccessedMembers(All)] Type appComponentType, ...). The resulting IL2072 is silenced with an UnconditionalSuppressMessage (line 191) instead of propagating the annotation.
The suppression justification says the Razor SDK trimming roots preserve these types "consistent with RootComponent.ComponentType" — but RootComponent.ComponentType is annotated with [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]; that annotation is exactly what makes the flow safe there, not an SDK root. Here the annotation chain is broken at the public entry point, so a component type assigned only via AppType (never referenced from a RootComponent, @rendermode in a rooted file, or routable assembly) can have its members trimmed and fail at render time in a trimmed/NativeAOT publish.
Annotate the property and its interface declaration instead of suppressing:
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public Type? AppType { get; set; }then the UnconditionalSuppressMessage at line 191 can be dropped.
There was a problem hiding this comment.
Agreed — this is the right call. I'll annotate public Type? AppType (and the IBlazorWebView declaration) with [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] so the trim contract propagates from the public entry point, and drop the UnconditionalSuppressMessage at line 191. You're right that the justification conflated an SDK root with the annotation on RootComponent.ComponentType — propagating the annotation is the correct fix, not silencing it.
| // unchanged; the rendered document is overlaid onto the file provider at this path. | ||
| if (value is not null && string.IsNullOrEmpty(HostPage)) | ||
| { | ||
| HostPage = AppTypeHostPage; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
AppType setter mutates HostPage (and AppType itself) as plain CLR state, but neither is a BindableProperty and neither raises Handler?.UpdateValue(...).
Consequence: AppType is only honoured if it is assigned before the handler is connected (as all three new device tests do). Setting it on an already-realized BlazorWebView — e.g. myBlazorWebView.AppType = typeof(App); from code-behind after the page is displayed — silently does nothing: MapAppType never re-runs and the webview keeps whatever host page it started with. There is also no coverage for this ordering.
At minimum add Handler?.UpdateValue(nameof(AppType)); at the end of the setter (and consider making it a BindableProperty for XAML/binding parity with HostPage/StartPath).
There was a problem hiding this comment.
Fair callout. Note this is consistent with the existing contract: neither HostPage nor RootComponents is a BindableProperty and neither re-runs on runtime re-assignment — they're all honoured only when set before the handler connects, which every real usage (XAML + the templates) does. So AppType matches the established set-before-connect semantics rather than introducing a new gap. Making it a BindableProperty that raises Handler?.UpdateValue(...) for post-connect re-assignment is a reasonable enhancement; I'll track it as a follow-up so this PR keeps parity with the sibling properties.
| } | ||
|
|
||
| // AppType provides a synthetic HostPage, so ensure the handler picks it up and attempts startup. | ||
| handler.HostPage = webView.HostPage; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
MapAppType is registered between MapHostPage and MapRootComponents, and its body is identical to what MapHostPage already did (handler.HostPage = webView.HostPage; handler.StartWebViewCoreIfPossible();). Because the AppType setter assigns the synthetic HostPage eagerly, MapHostPage has already copied the same value and already attempted startup by the time this runs — so this mapper adds no behaviour on the happy path.
What it does add is an ordering hazard: it invokes StartWebViewCoreIfPossible() while handler.RootComponents is still null (that field is only assigned in MapRootComponents, which runs after this entry). If startup succeeds at this point, the platform StartWebViewCoreIfPossible (see Android/BlazorWebViewHandler.Android.cs ~L197 if (RootComponents != null)) creates _webviewManager and adds zero root components — including the AppType-derived registrations that CreateFileProvider just appended to VirtualView.RootComponents — and the subsequent MapRootComponents call returns early on _webviewManager != null, so nothing ever attaches and the page renders as a static document with a dead #app div.
Either delete this mapper (redundant, and it is now permanent public API — see PublicAPI.Unshipped.txt), or move the AppType entry after RootComponents in the mapper so the ordering invariant is explicit.
There was a problem hiding this comment.
The ordering observation is correct in the abstract, but the predicted outcome (startup with zero components → dead #app) does not manifest — the three AppType tests pass on Android (44/48, 0 failed, all 3 AppType methods green) and MacCatalyst (45/46), and the legacy tests in the same class pass too. I re-ran the full BlazorWebView device-test class on a real arm64 Android 16 emulator to confirm.
Why it works: MapRootComponents does handler.RootComponents = webView.RootComponents — a reference assignment to the same RootComponentsCollection. At the first successful StartWebViewCoreIfPossible (gated on PlatformView+Services, which is ready at/after the RootComponents map), CreateFileProvider (Android L178) runs EnsureAppTypeRendered, which appends the AppType registrations to that shared collection before the foreach (RootComponents) at L197 consumes it. So the registrations are visible and attach correctly.
That said, this correctness currently relies on that reference-sharing + connection-ordering coincidence, which is exactly the fragility you're pointing at. I'll harden it by registering the derived root components and rendering the host document before StartWebViewCoreIfPossible, so the AppType path is robust regardless of mapper ordering, and simplify/remove this now-redundant mapper (I'll keep the null early-return behavior). Thanks — this is the most useful structural note in the review.
| return; | ||
| } | ||
|
|
||
| _appTypeRendered = true; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
_appTypeRendered = true is set before HybridHostPageRenderer.Render runs, and it is never reset on failure.
If Render throws (a component OnInitializedAsync failure, a missing DI service, an invalid AppType), the first CreateFileProvider call propagates the exception, but _renderedHostPageHtml stays null and the flag stays true. Any later attempt — handler reconnect after a Shell tab switch, window re-creation, or a second BlazorWebView startup — skips rendering entirely, constructs BlazorWebViewFileProvider with hostPageHtml: null, and therefore serves nothing at wwwroot/index.html. The user sees a permanently blank WebView with no error instead of the original exception.
Set the flag only after a successful render (or store the failure and rethrow it on subsequent calls) so the failure mode stays diagnosable.
There was a problem hiding this comment.
Confirmed, and this one is a real latent bug — _appTypeRendered = true is set before Render() runs, so a render exception (bad OnInitializedAsync, missing DI service, invalid AppType) latches a permanently-blank state: subsequent reconnects skip rendering and construct the provider with hostPageHtml: null. I'll set the flag only after a successful render, and store+rethrow the captured failure on later calls so it stays diagnosable instead of silently blank.
| } | ||
|
|
||
| // Any other interactive root becomes a mount element that the live component attaches to. | ||
| _registrations.Add(new HybridRootComponentRegistration(AppSelector, componentType)); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
❌ Logic / correctness — duplicate mount element — Every non-HeadOutlet interactive component is registered against the same hard-coded AppSelector (#app) and emits its own <div id="app"></div> mount element (line 126).
A host document with two interactive boundaries — which the XML docs explicitly invite (<Routes @rendermode="InteractiveAuto" /> plus any second @rendermode component, e.g. a sidebar or a status bar) — therefore produces a document with two elements sharing id="app", and two root-component registrations both targeting #app. document.querySelector('#app') returns only the first, so both components attach to the same node: one silently never renders (or overwrites the other), and the second <div id="app"> stays empty. There is no diagnostic — the app just loses a component.
Either generate a unique element id per registration (app, app-1, …) and register the matching selector, or explicitly throw when a second non-HeadOutlet interactive root is encountered so the limitation is visible. The three added device tests each declare exactly one interactive non-head component, so this case is untested.
There was a problem hiding this comment.
Correct — two non-head interactive roots would both emit id="app" and both register #app, so only the first mounts. Today the host document has a single interactive router (matching the Blazor Web App template), so it doesn't bite, but nothing enforces that. I'll generate a unique element id per registration (app, app-1, …) with the matching selector so multiple interactive boundaries mount independently, and add a device test with two interactive non-head roots to lock it in.
| public long Length => _contents.Length; | ||
| public string? PhysicalPath => null; | ||
| public string Name { get; } | ||
| public DateTimeOffset LastModified => DateTimeOffset.UtcNow; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
💡 Logic / correctness — LastModified => DateTimeOffset.UtcNow evaluates on every access, so the same IFileInfo reports a different timestamp each time it is read.
IFileInfo.LastModified is expected to be a stable property of the file. Static-content pipelines derive Last-Modified/ETag and conditional-request handling from it; a value that changes per call makes the host page unconditionally non-cacheable and can produce inconsistent headers within a single response if the value is read more than once. Capture it once in the constructor (DateTimeOffset.UtcNow at construction, or better a deterministic value derived from the content) and return the stored field.
There was a problem hiding this comment.
Confirmed — LastModified => DateTimeOffset.UtcNow recomputes on every read, so the same IFileInfo reports a moving timestamp, making the host page unconditionally non-cacheable and potentially header-inconsistent within one response. I'll capture it once in the constructor and return the stored field.
| continue; | ||
| } | ||
|
|
||
| var labelMatch = Regex.Match(properties, "\"Name\"\\s*:\\s*\"label\"\\s*,\\s*\"Value\"\\s*:\\s*\"(?<v>(?:[^\"\\\\]|\\\\.)*)\""); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
EndpointProperties metadata of StaticWebAssetEndpoint with regexes over its raw JSON text, and this one additionally hard-codes the property ordering inside each object ("Name": "label" must be immediately followed by "Value": "...").
Neither the JSON member order nor the whitespace/serializer shape of EndpointProperties is a documented contract of the .NET SDK — it is an implementation detail of DefineStaticWebAssetEndpoints. If the SDK ever serialises {"Value":"…","Name":"label"} (or inserts another member between them), labelMatch.Success is false, the endpoint is skipped with continue, and the target emits an empty-but-valid {"Assets":[]} manifest. That failure is completely silent: the build succeeds, the manifest is bundled, and the breakage only shows up as 404s for every fingerprinted asset at runtime.
Also note line 111's Regex.IsMatch(properties, "\"Name\"\\s*:\\s*\"fingerprint\"") matches anywhere in the blob, including inside another property's value. Prefer parsing the metadata structurally, or at minimum emit a <Warning> when endpoints are present but zero entries were extracted, so a shape change fails loudly at build time rather than silently at runtime.
There was a problem hiding this comment.
Fair — parsing EndpointProperties with regex, and assuming "Name" is immediately followed by "Value", both lean on the undocumented serialization shape of DefineStaticWebAssetEndpoints. If the SDK ever reorders those members the target silently emits {"Assets":[]} and every fingerprinted asset 404s at runtime. At minimum I'll emit a build <Warning> when endpoints are present but zero entries were extracted, so a shape change fails loudly at build time instead of silently at runtime, and I'll look at parsing the metadata structurally rather than by regex.
| </UsingTask> | ||
|
|
||
| <Target Name="_BundleMauiBlazorAssetManifest" | ||
| AfterTargets="ConvertStaticWebAssetsToMauiAssets" |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
💡 Build / MSBuild — incremental & publish correctness — Two issues with this target definition:
- No
Inputs/Outputs, so the inline task re-runs and re-scans everyStaticWebAssetEndpointon every build (including design-time-adjacent invocations). The task body does guard the write with a content comparison (line 152), so incremental output is clean, but the scan cost is paid unconditionally. Directory.CreateDirectory(Path.GetDirectoryName(OutputFile))(line 150) throwsArgumentExceptionifOutputFileever resolves to a bare filename (GetDirectoryNamereturnsstring.Empty). It is currently always prefixed with$(IntermediateOutputPath), but the task is written as a reusableUsingTaskwith aRequiredpublic parameter — a null/empty guard would make the failure mode a clear message rather than an opaque task crash.
Also worth confirming: the manifest is generated from the build StaticWebAssetEndpoint set, while ConvertStaticWebAssetsToMauiAssets depends on both StaticWebAssetsPrepareForRun and StaticWebAssetsPrepareForPublish. If publish-time asset fingerprints can differ from build-time ones, the bundled manifest routes would not match the published files and @Assets URLs would 404 in a published app — please verify a dotnet publish (not just build) of a hybrid app with a fingerprinted asset.
There was a problem hiding this comment.
Good points. I'll add a null/empty guard for OutputFile's directory (so a bare filename gives a clear message rather than an opaque ArgumentException), and — the important one — verify a dotnet publish (not just build) of a hybrid app with a fingerprinted asset, to confirm publish-time fingerprints match the bundled build-time manifest. Incremental Inputs/Outputs is worth adding too, though the write itself is already content-guarded so output stays clean.
| // and fingerprinted-route serving work. The manifest lives outside the web root and is read | ||
| // from the app package, so it is never served to the web view. Absent (or on platforms | ||
| // without app-package access), fingerprinting simply stays off. | ||
| var manifest = StaticWebAssetsManifest.TryLoad(); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
💡 Performance / lifecycle — StaticWebAssetsManifest.TryLoad() is called on every CreateFileProvider invocation, i.e. once per handler start. Each call does a blocking Task.Run(...).GetAwaiter().GetResult() on the UI thread that hits the platform app-package APIs and re-parses the JSON from scratch.
For an app with several BlazorWebView instances, or a view that is reconnected across Shell tab switches / window re-creations, the same immutable, build-time-generated manifest is read and deserialized repeatedly while blocking the UI thread. Unlike _renderedHostPageHtml, this result is not cached. A static lazily-initialized cache (the manifest cannot change during the process lifetime) would remove the repeat cost entirely.
There was a problem hiding this comment.
Agreed — the manifest is immutable build output, but it's re-read from the app package and re-parsed (via a blocking Task.Run(...).GetAwaiter().GetResult()) on every CreateFileProvider, i.e. once per handler start, and unlike _renderedHostPageHtml it's not cached. I'll add a static lazily-initialized cache since it can't change during the process lifetime — removes the repeat cost and the repeated UI-thread block for multi-BlazorWebView / reconnect scenarios.
|
|
||
| // Provide a synthetic host page so the existing startup and relative-path logic flows | ||
| // unchanged; the rendered document is overlaid onto the file provider at this path. | ||
| if (value is not null && string.IsNullOrEmpty(HostPage)) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
💡 Logic / correctness — asymmetric setter — The setter assigns the synthetic HostPage when AppType is set, but performs no inverse action when AppType is set back to null: HostPage is left pointing at "wwwroot/index.html", and _renderedHostPageHtml / _appTypeRendered retain their values.
If a caller clears AppType (or sets it before deciding on a different host page), the view is left claiming a host page file that may not exist on disk, and CreateFileProvider now takes the AppType is null early-return (line 172) and returns the bare platform provider — so nothing serves wwwroot/index.html and the WebView renders blank. Either restore the previous HostPage when clearing AppType, or reject the transition with a clear exception.
There was a problem hiding this comment.
Correct edge case — clearing AppType after it was set leaves the synthetic HostPage (wwwroot/index.html) in place and keeps _renderedHostPageHtml/_appTypeRendered, while CreateFileProvider now takes the AppType is null early-return and serves the bare platform provider, so nothing renders. I'll make the setter symmetric: restore the previous HostPage and reset the rendered state when AppType is cleared (falling back to a clear exception if the transition is ambiguous). This pairs with the failure-latch fix above.
This comment has been minimized.
This comment has been minimized.
Switch both the single-project (maui-blazor) and solution (maui-blazor-solution) Blazor Hybrid templates from a static wwwroot/index.html host to a full-document App.razor host component driven by BlazorWebView.AppType, aligning the hybrid templates with the Blazor Web App model. - Add Components/App.razor (full HTML document, HeadOutlet + Routes with @rendermode InteractiveAuto, @assets fingerprinting, blazor.webview.js) - Add '@using static Microsoft.AspNetCore.Components.Web.RenderMode' to _Imports.razor - MainPage.xaml: replace HostPage + RootComponents with AppType='{x:Type components:App}' - Remove wwwroot/index.html (host document now provided by App.razor) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 10 findings
See inline comments for details.
|
|
||
| // Render on a thread-pool thread so the renderer's dispatcher never contends with the UI | ||
| // synchronization context. | ||
| return Task.Run(() => |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[critical] Async and Threading Safety — Render blocks the calling thread with Task.Run(...).GetAwaiter().GetResult() while awaiting rootComponent.QuiescenceTask, and the only caller (BlazorWebView.CreateFileProvider → EnsureAppTypeRendered) runs on the UI thread inside the handler mapper sweep (MapHostPage → StartWebViewCoreIfPossible → VirtualView.CreateFileProvider). Concrete scenario: an App.razor (or any component it statically renders) whose OnInitializedAsync awaits work that must complete on the MAUI main thread — MainThread.InvokeOnMainThreadAsync, IDispatcher.DispatchAsync, or any MAUI service that marshals to the UI thread — never resumes, because the UI thread is blocked here. The result is a hard app hang at startup with no timeout and no cancellation. ConfigureAwait(false) does not help: the continuation is posted to the MAUI dispatcher, not captured by the renderer's dispatcher. Consider making the host render asynchronous (render before/independently of CreateFileProvider), or at minimum bound the quiescence wait and surface a diagnosable failure instead of an indefinite UI-thread block.
There was a problem hiding this comment.
Noted (same thread as the earlier HybridHostPageRenderer.cs:104 finding — logged there too). Agreed the Task.Run(...).GetAwaiter().GetResult() blocks the UI thread and a host component that marshals to the main thread in OnInitializedAsync would deadlock. Plan: render the host document asynchronously ahead of CreateFileProvider (during connect) rather than blocking, and as a safety net bound the quiescence wait with a diagnosable timeout instead of an indefinite block. Documenting the "no main-thread-dispatching async init" constraint in the interim.
| // Offload to the thread pool and block: the downstream static-content pipeline that | ||
| // consumes this is synchronous, and the platform app-package readers complete | ||
| // synchronously anyway. Matches how the host document is rendered. | ||
| return Task.Run(LoadAsync).GetAwaiter().GetResult(); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Async and Threading Safety — TryLoad() performs sync-over-async app-package I/O (Task.Run(LoadAsync).GetAwaiter().GetResult()) and is called from BlazorWebView.CreateFileProvider, which executes on the UI thread during handler connect. FileSystem.AppPackageFileExistsAsync + OpenAppPackageFileAsync are not guaranteed to complete synchronously on all platforms (Android AssetManager open, Windows StorageFile APIs are genuinely async), so this blocks the UI thread on I/O for the duration of the lookup on every CreateFileProvider call — including handler re-connect (e.g. Shell tab switch, page re-appearance). The comment asserting "the platform app-package readers complete synchronously anyway" is not true for the Windows StorageFile-based path.
There was a problem hiding this comment.
Good catch on the comment — it's inaccurate for Windows: StorageFile-based OpenAppPackageFileAsync is genuinely async, so this can block the UI thread on real I/O per CreateFileProvider (including reconnect). I'll (a) fix the misleading comment, and (b) add the static lazily-initialized cache I mentioned on BlazorWebView.cs:180 so the package read + parse happens once per process instead of once per handler start — which removes the repeated UI-thread I/O block entirely.
| return; | ||
| } | ||
|
|
||
| _appTypeRendered = true; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Logic and Correctness Verification — _appTypeRendered = true is set before HybridHostPageRenderer.Render(...) executes, so a render failure permanently latches the "already rendered" state while _renderedHostPageHtml stays null. Concrete scenario: the user's App.razor throws during static render (or the quiescence wait faults). The exception propagates out of CreateFileProvider and aborts the first handler connect; on the next connect (Shell tab switch, page pushed again, window re-created) a new handler calls CreateFileProvider again, EnsureAppTypeRendered early-returns, and BlazorWebViewFileProvider is constructed with hostPageHtml == null. The in-memory host page is then never served and the request for wwwroot/index.html falls through to the physical provider — where the templates in this PR have deleted index.html — producing a silently blank WebView with no error. Set the flag only after a successful render (or cache the failure explicitly and rethrow), so the failure is not converted into a silent blank page.
There was a problem hiding this comment.
Confirmed — and you're right that the template index.html deletion sharpens this: after a latched render failure there's no physical fallback, so it's a silent blank page. I'll set _appTypeRendered only after a successful render and capture+rethrow the failure on subsequent calls, so a broken App.razor surfaces as a diagnosable error rather than a blank WebView.
| return new BlazorWebViewFileProvider(platformFileProvider, hostPageRelativePath, _renderedHostPageHtml, manifest); | ||
| } | ||
|
|
||
| [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2072", |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Trimming and AOT Compatibility — This UnconditionalSuppressMessage("Trimming", "IL2072") is applied to the whole EnsureAppTypeRendered method and hides a genuinely reachable dynamic-metadata path: the unannotated AppType (Type?, no DynamicallyAccessedMembers) flows into HybridHostPageRenderer.Render's [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] parameter, and from there into BeginRenderingComponent and ResolveComponentForRenderMode (also DAM.All). The justification — "preserved by the Razor SDK trimming roots, consistent with RootComponent.ComponentType" — does not hold as written: RootComponent.ComponentType carries the DAM annotation itself rather than relying on a suppression. The structural fix is to annotate the API instead of suppressing: put [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] on BlazorWebView.AppType and on IBlazorWebView.AppType, which propagates the requirement to callers and removes the warning at its source. As written, a component type reached only through AppType (e.g. assigned in code rather than via a {x:Type} XAML reference) can have members trimmed in a published trimmed/NativeAOT app.
There was a problem hiding this comment.
Agreed — I'll annotate BlazorWebView.AppType and IBlazorWebView.AppType with [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] so the requirement propagates to callers, and remove this method-wide UnconditionalSuppressMessage. You're right the justification was wrong — RootComponent.ComponentType carries the annotation itself, it isn't relying on an SDK root.
|
|
||
| // AppType provides a synthetic HostPage, so ensure the handler picks it up and attempts startup. | ||
| handler.HostPage = webView.HostPage; | ||
| handler.StartWebViewCoreIfPossible(); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Handler Mapper and Property Patterns — MapAppType has no reachable effect, yet it is added permanently to the public API surface (static BlazorWebViewHandler.MapAppType is listed in every PublicAPI.Unshipped.txt in this PR). Trace: the AppType setter already assigns HostPage (BlazorWebView.cs:87), and MapHostPage — which runs first in BlazorWebViewMapper — sets handler.HostPage and calls StartWebViewCoreIfPossible(). By the time MapAppType runs, _webviewManager is non-null and StartWebViewCoreIfPossible() returns immediately at its _webviewManager != null guard, and handler.HostPage = webView.HostPage re-assigns the identical value. The mapper also cannot fire later, because AppType is a plain CLR property that never calls Handler.UpdateValue(nameof(AppType)). Either wire the property to invalidate through the mapper (so the entry is meaningful) or drop the mapper entry and the public MapAppType method — a public static method that cannot be removed once shipped should not be dead.
There was a problem hiding this comment.
This is the accurate framing — thanks for correcting the earlier ordering theory. Agreed MapAppType is effectively a no-op today (MapHostPage runs first and starts, the _webviewManager != null guard short-circuits this, and AppType never calls UpdateValue), yet it's now permanent public API. I'll drop the dead MapAppType entry + public method as part of moving the AppType render/registration to happen explicitly before StartWebViewCoreIfPossible (so nothing relies on the mapper firing at all).
| // unchanged; the rendered document is overlaid onto the file provider at this path. | ||
| if (value is not null && string.IsNullOrEmpty(HostPage)) | ||
| { | ||
| HostPage = AppTypeHostPage; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Handler Mapper and Property Patterns — Setting AppType after the handler is connected silently does nothing: the setter mutates _appType/HostPage but never calls Handler?.UpdateValue(nameof(AppType)), and EnsureAppTypeRendered is latched by _appTypeRendered anyway. The setter also has an order-dependent side effect — it only defaults HostPage when HostPage is currently empty — so <BlazorWebView HostPage="..." AppType="..."/> and <BlazorWebView AppType="..." HostPage="..."/> behave differently in XAML (attribute order determines whether the rendered document is overlaid at the user's host-page path or at the synthetic wwwroot/index.html). At minimum document that AppType must be assigned before the handler is created, and make the HostPage interaction explicit rather than silently order-sensitive.
There was a problem hiding this comment.
Valid and a genuinely new angle — the attribute-order sensitivity is a real footgun: because the setter only defaults HostPage when it's currently empty, <BlazorWebView HostPage="..." AppType="..."/> overlays at the user's host-page path while <BlazorWebView AppType="..." HostPage="..."/> overlays at the synthetic wwwroot/index.html. I'll make this explicit rather than order-sensitive: AppType and an explicit HostPage are mutually exclusive, so I'll either reject the combination with a clear exception or define a single deterministic precedence regardless of attribute order, and document it.
|
|
||
| foreach (var registration in result.Registrations) | ||
| { | ||
| RootComponents.Add(new RootComponent |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Logic and Correctness Verification — EnsureAppTypeRendered mutates the caller-owned public RootComponents collection as a side effect of CreateFileProvider. Concrete scenario: a user migrating to AppType keeps their existing <RootComponent Selector="#app" ComponentType="{x:Type Routes}"/> while the host document also declares <Routes @rendermode="InteractiveAuto"/>. Both entries now target #app, so two component instances are attached to the same mount element. There is no duplicate-selector detection and no diagnostic. Either de-duplicate by selector before adding, or throw/log when an AppType-derived registration collides with a user-declared RootComponent selector.
There was a problem hiding this comment.
Agreed (pairs with the HybridHostPageRenderer.cs:125 thread). The migration scenario you describe — user keeps <RootComponent Selector="#app"/> and the host doc also declares <Routes @rendermode> — would attach two components to #app with no diagnostic. I'll de-duplicate by selector before adding and throw/log on an AppType-vs-user-declared collision, plus generate unique mount ids for multiple host-document interactive roots.
| continue; | ||
| } | ||
|
|
||
| var labelMatch = Regex.Match(properties, "\"Name\"\\s*:\\s*\"label\"\\s*,\\s*\"Value\"\\s*:\\s*\"(?<v>(?:[^\"\\\\]|\\\\.)*)\""); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Build & MSBuild — The fingerprint manifest is produced by regex-scraping the EndpointProperties JSON blob of StaticWebAssetEndpoint items. This pattern requires "Name" to be serialized immediately before "Value" within each property object; it is an undocumented ordering detail of the SDK's endpoint serialization, and any change (reordered members, added whitespace/members between them) silently drops every asset. The failure is silent by design — Regex.Match fails → continue → empty manifest → fingerprinting quietly turns off — so a future SDK bump would regress @Assets fingerprinting with a green build and no diagnostic. Parse the JSON properly (the endpoint metadata is well-formed JSON) or, at minimum, emit an MSBuild warning when StaticWebAssetEndpoint items exist but zero fingerprinted entries are extracted, so the regression is visible.
There was a problem hiding this comment.
Agreed (same as the earlier targets:117 thread). I'll emit an MSBuild <Warning> when StaticWebAssetEndpoint items exist but zero fingerprinted entries are extracted, so an SDK serialization-shape change fails loudly at build time instead of silently disabling fingerprinting, and move toward structural JSON parsing of the endpoint metadata rather than regex.
| @@ -0,0 +1,132 @@ | |||
| using System; | |||
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Regression Prevention and Test Coverage — Coverage is device-test-only and covers only the happy path. Three testable behaviours introduced by this PR have no test at all: (1) BlazorWebViewFileProvider.GetFileInfo fall-through and fingerprinted-route resolution — including the case where the in-memory host page is absent (hostPageHtml == null), which is exactly the silent-blank-page path described on BlazorWebView.cs:200; (2) StaticWebAssetsManifest.Parse/FromData against a missing, empty and malformed manifest, and TryResolvePhysicalPath when route == label; (3) AppType combined with an explicitly-set HostPage, and AppType combined with a user-declared RootComponent on #app. All three are internal types with no platform dependency and belong in a fast unit-test project rather than in device tests. Also note the negative case is untested: there is no test asserting that AppType == null leaves the legacy HostPage path byte-for-byte unchanged (the CreateFileProvider early-return at BlazorWebView.cs:172), which is the PR's central compatibility claim.
There was a problem hiding this comment.
Agreed, and the specific gaps are well chosen. I'll add a fast unit-test project covering: (1) BlazorWebViewFileProvider.GetFileInfo fall-through + fingerprinted-route resolution, including the hostPageHtml == null blank-page path; (2) StaticWebAssetsManifest.Parse/FromData over missing/empty/malformed manifests and TryResolvePhysicalPath when route == label; (3) AppType + explicit HostPage, and AppType + a user #app RootComponent. And importantly the negative case you flagged — asserting AppType == null leaves the legacy CreateFileProvider early-return path unchanged (the core compat claim). These are all internal, platform-free types, so unit tests fit.
| <RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" /> | ||
| </BlazorWebView.RootComponents> | ||
| </BlazorWebView> | ||
| <BlazorWebView x:Name="blazorWebView" AppType="{x:Type components:App}" /> |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Regression Prevention and Test Coverage — Both shipping templates are switched to AppType and their wwwroot/index.html is deleted in the same change, which makes every new Blazor Hybrid app depend on the entire new pipeline at once: the static host render, the build-time manifest target, @rendermode conversion, HeadOutlet attach at head::after, and @Assets fingerprint resolution. Per the MAUI template rule, a change to maui-blazor must be validated against all affected template IDs (maui-blazor and maui-blazor-solution) end to end — create → restore → build → run on each platform — and no template/integration test evidence is present in this PR. This is the highest-blast-radius part of the change: if the manifest target does not bundle _maui/blazor-asset-manifest.json on a given platform, or the static render blocks as described in HybridHostPageRenderer.cs:79, the default template no longer has a physical index.html to fall back to. Recommend keeping the template migration as a separate follow-up PR gated on template integration test results.
There was a problem hiding this comment.
Fair, and this is the right strategic call. The template switch + index.html deletion is the highest-blast-radius part of the change, and it isn't yet backed by end-to-end template integration evidence (create → restore → build → run per platform) for both maui-blazor and maui-blazor-solution. I've validated the templates structurally (instantiate + Microsoft.Maui.Templates.csproj build) and the underlying pipeline via device tests on Android + MacCatalyst, but not the full per-platform template run. I'll split the template migration into a separate follow-up PR gated on template integration tests, and keep this PR focused on the AppType feature + the fixes from this review. Good call.
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 16 findings
See inline comments for details.
| return; | ||
| } | ||
|
|
||
| _appTypeRendered = true; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Logic and Correctness — _appTypeRendered = true is latched before the render runs, so a failed render is permanently sticky. If HybridHostPageRenderer.Render throws (an exception in the host component's OnInitializedAsync, a NotSupportedException from an unhandled render mode, or the services null throw above), _renderedHostPageHtml stays null and the flag stays true. On the next handler connect — Shell tab switch or page re-navigation, where DisconnectHandler sets _webviewManager = null and StartWebViewCoreIfPossible() runs again — EnsureAppTypeRendered returns immediately, BlazorWebViewFileProvider is constructed with hostPageHtml == null so no in-memory host page is registered, and the request for wwwroot/index.html falls through to the platform provider where that file does not exist (the templates delete it). Result: a permanently blank WebView with no exception and no log. Set the flag only after Render returns, or record the failure and rethrow on subsequent attempts.
| /// live document, so an explicit <see cref="RootComponents"/> entry is not required either. | ||
| /// </para> | ||
| /// </summary> | ||
| public Type? AppType |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Handler Mapper and Property Patterns — the AppType setter never invalidates the render cache and its HostPage side effect is irreversible.
- Assigning a different
AppTypeafter the firstCreateFileProviderleaves_appTypeRendered == trueand_renderedHostPageHtmlholding the previous component's document, so the old host page keeps being served while the old component'sRootComponentsentries (added inEnsureAppTypeRendered) remain in the collection — the new document never appears and stale roots accumulate. - The setter writes
HostPage = AppTypeHostPagebut settingAppType = nulllater does not undo it, so the view is left pointing atwwwroot/index.html, a file the migrated templates no longer ship → blank page.
Reset _appTypeRendered/_renderedHostPageHtml, remove the previously-registered roots, and restore HostPage when the value changes (or document AppType as write-once and throw on reassignment after render).
| } | ||
|
|
||
| // Any other interactive root becomes a mount element that the live component attaches to. | ||
| _registrations.Add(new HybridRootComponentRegistration(AppSelector, componentType)); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Logic and Correctness — every non-HeadOutlet interactive boundary is mapped to the same element id and selector (AppElementId/AppSelector are constants). A host document that declares two interactive components (e.g. <Routes @rendermode="InteractiveAuto" /> plus any second <Foo @rendermode="..." />, which is legal in a Blazor Web App App.razor) statically renders two <div id="app"></div> elements — duplicate DOM ids — and registers two root components against the selector #app. Blazor's attach resolves the selector with document.querySelector, so both roots bind to the first div and the second component overwrites/nests inside the first. Allocate a unique id per registration (app, app-1, …) or fail fast with a diagnostic naming the second component type.
| renderer.Dispose(); | ||
| } | ||
| }); | ||
| }).GetAwaiter().GetResult(); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Async and Threading Safety — Task.Run(...).GetAwaiter().GetResult() blocks the calling thread, and the calling thread is the UI thread: CreateFileProvider is invoked from BlazorWebViewHandler.StartWebViewCoreIfPossible() (Android BlazorWebViewHandler.Android.cs:178 and the iOS/Windows equivalents), which runs inside the property mapper during handler connect. The blocked work awaits rootComponent.QuiescenceTask, i.e. arbitrary user code in the host component's OnInitializedAsync. Any await in that user code that must resume on the MAUI main thread (MainThread.InvokeOnMainThreadAsync, IDispatcher.DispatchAsync, a main-thread-affine MAUI service, a platform API marshalled to the UI thread) can never complete, because its continuation is queued to the thread that is blocked here → permanent silent startup hang with no exception. Moving the Task.Run off the UI thread does not help; the GetResult() is the deadlock. Either render before/independently of the synchronous CreateFileProvider contract, or document + guard that the host component must not await main-thread work.
| return new BlazorWebViewFileProvider(platformFileProvider, hostPageRelativePath, _renderedHostPageHtml, manifest); | ||
| } | ||
|
|
||
| [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2072", |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Trimming and AOT Compatibility — this UnconditionalSuppressMessage("Trimming", "IL2072") hides a reachable annotation gap rather than an unreachable path. AppType is a public, unannotated System.Type? property, and HybridHostPageRenderer.Render requires [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] on its appComponentType parameter — the flow from the unannotated property to the annotated parameter is exactly what IL2072 reports, and it executes on every AppType startup. The justification cites RootComponent.ComponentType, but that member solves this with [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] on the property, not with a suppression. Under PublishTrimmed/NativeAOT, a component type reaching AppType through a Type value the Razor SDK roots do not fully preserve can have members trimmed and fail at render time with no build diagnostic. Structural fix: annotate BlazorWebView.AppType and IBlazorWebView.AppType with [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] and delete the suppression.
| IComponentRenderMode renderMode) | ||
| { | ||
| // The mode value is documentary — all interactive modes are intercepted identically. | ||
| if (renderMode is InteractiveServerRenderMode or InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Logic and Correctness — only the three concrete built-in mode types are intercepted. Any other IComponentRenderMode — a user-defined mode, or a mode type added by a future ASP.NET Core release — falls through to base.ResolveComponentForRenderMode, which on StaticHtmlRenderer throws NotSupportedException. That throw surfaces from inside the blocking GetResult() on the UI thread during handler connect, so the app crashes at startup with a stack that does not name the offending component or mode. Add an explicit else that throws with the component type and render mode in the message, or treat any non-static IComponentRenderMode as interactive.
| // Offload to the thread pool and block: the downstream static-content pipeline that | ||
| // consumes this is synchronous, and the platform app-package readers complete | ||
| // synchronously anyway. Matches how the host document is rendered. | ||
| return Task.Run(LoadAsync).GetAwaiter().GetResult(); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Async and Threading Safety / Null Safety — two issues on this line. (1) Task.Run(...).GetAwaiter().GetResult() blocks the UI thread (this is reached from CreateFileProvider during handler connect) on app-package file existence checks, file open, and JSON deserialization, adding synchronous I/O to every AppType startup. (2) The surrounding catch (Exception) swallows every failure, including a malformed or schema-drifted manifest, and silently degrades to "no fingerprinting". The user-visible symptom is @Assets["…"] emitting unfingerprinted URLs that then 404 — broken stylesheets/images — with nothing logged. Log the swallowed exception through the existing BlazorWebView logger so the degradation is diagnosable.
| builder.Append("]}"); | ||
|
|
||
| var content = builder.ToString(); | ||
| Directory.CreateDirectory(Path.GetDirectoryName(OutputFile)); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Build & MSBuild — Directory.CreateDirectory(Path.GetDirectoryName(OutputFile)) throws ArgumentException when OutputFile has no directory component (Path.GetDirectoryName returns string.Empty). OutputFile is $(IntermediateOutputPath)maui\blazor-asset-manifest.json today, but IntermediateOutputPath is user-overridable and the task is a public-ish UsingTask; guard with var dir = Path.GetDirectoryName(OutputFile); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);.
| </Task> | ||
| </UsingTask> | ||
|
|
||
| <Target Name="_BundleMauiBlazorAssetManifest" |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[minor] Build & MSBuild — _BundleMauiBlazorAssetManifest is gated only on '@(StaticWebAssetEndpoint)' != '', so it runs and bundles _maui/blazor-asset-manifest.json into every Blazor Hybrid app, including the large majority that use the existing HostPage path and never read the manifest. Consider gating on an opt-in property (or on the presence of a RazorComponent host) so non-AppType apps do not grow their package for an unused file. Also note the label-extraction regex above assumes the SDK serializes EndpointProperties with "Name" immediately preceding "Value"; if that ordering ever changes the match silently fails and fingerprinting silently turns off — a targeted unit test over a captured EndpointProperties payload would pin the contract.
| <!--#endif --> | ||
| <link rel="stylesheet" href="app.css" /> | ||
| <link rel="stylesheet" href="MauiApp.1.styles.css" /> | ||
| @*#if (SampleContent) --> |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention — needs verification — the HTML conditional <!--#if (SampleContent) --> / <!--#endif --> was rewritten as @*#if (SampleContent) --> … ##endif*@, but no .razor conditional configuration was added to .template.config/template.json in this PR (the only .razor entries there are primaryOutputs/postActions paths). Two concrete failure modes if the engine does not process this form for .razor: the whole block stays a Razor comment and the bootstrap stylesheet is silently dropped from dotnet new maui-blazor with SampleContent; or the block is processed but the stray --> is emitted as literal text into <head>. This is user-visible in the shipped template and needs an actual dotnet new maui-blazor instantiation both with and without --sample-content before merge. The same pattern is used in maui-blazor-solution/MauiApp.1/Components/App.razor.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@mattleibow — new AI review results are available based on commit
7781ad5.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ⚠️ INCONCLUSIVE
Platform: IOS · Base: net11.0 · Merge base: ec79089f
🩺 Could not verify — environment/infrastructure error. The gate ran the tests but hit an environment error (an emulator/simulator/Appium/XHarness flake, a device that would not boot, or an empty/invalid result file), so it could not record a real pass/fail. The /review to retry on a fresh agent.
XHarness did not produce the expected fresh result 'testResults.xml' for requested class(es) 'Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements.BlazorWebViewTests' (the target tests did not run).
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
📱 BlazorWebViewTests (AppTypeRendersHostDocumentAndAttachesComponent, AppTypeSupportsDynamicHeadViaHeadOutlet, AppTypeResolvesFingerprintedAssetsViaAssets) Category=BlazorWebView |
🛠️ BUILD ERROR |
🔴 Without fix — 📱 BlazorWebViewTests (AppTypeRendersHostDocumentAndAttachesComponent, AppTypeSupportsDynamicHeadViaHeadOutlet, AppTypeResolvesFingerprintedAssetsViaAssets): 🛠️ BUILD ERROR · 42s
Error-relevant lines (filtered from the build log):
/Users/cloudtest/vss/_work/1/s/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.AppType.cs(25,4): error CS0117: 'BlazorWebView' does not contain a definition for 'AppType' [/Users/cloudtest/vss/_work/1/s/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj::TargetFramework=net11.0-ios]
/Users/cloudtest/vss/_work/1/s/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.AppType.cs(66,4): error CS0117: 'BlazorWebView' does not contain a definition for 'AppType' [/Users/cloudtest/vss/_work/1/s/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj::TargetFramework=net11.0-ios]
/Users/cloudtest/vss/_work/1/s/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.AppType.cs(98,4): error CS0117: 'BlazorWebView' does not contain a definition for 'AppType' [/Users/cloudtest/vss/_work/1/s/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj::TargetFramework=net11.0-ios]
Build FAILED.
🟢 With fix — 📱 BlazorWebViewTests (AppTypeRendersHostDocumentAndAttachesComponent, AppTypeSupportsDynamicHeadViaHeadOutlet, AppTypeResolvesFingerprintedAssetsViaAssets): ⚠️ ENV ERROR · 139s
No log file found
⚠️ Failure Details
- 🛠️ BlazorWebViewTests (AppTypeRendersHostDocumentAndAttachesComponent, AppTypeSupportsDynamicHeadViaHeadOutlet, AppTypeResolvesFingerprintedAssetsViaAssets) without fix: build failed before tests could run
/Users/cloudtest/vss/_work/1/s/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.AppType.cs(25,4): error CS0117: 'BlazorWebView' does not contain a definition for 'AppType' [/Users/cloud...
⚠️ BlazorWebViewTests (AppTypeRendersHostDocumentAndAttachesComponent, AppTypeSupportsDynamicHeadViaHeadOutlet, AppTypeResolvesFingerprintedAssetsViaAssets) with fix:XHarness did not produce the expected fresh result 'testResults.xml' for requested class(es) 'Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements.BlazorWebViewTests' (the target tests did not run).
📁 Fix files reverted (15 files)
src/BlazorWebView/src/Maui/BlazorWebView.cssrc/BlazorWebView/src/Maui/BlazorWebViewHandler.cssrc/BlazorWebView/src/Maui/IBlazorWebView.cssrc/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txtsrc/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txtsrc/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txtsrc/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txtsrc/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txtsrc/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txtsrc/BlazorWebView/src/Maui/build/Microsoft.AspNetCore.Components.WebView.Maui.targetssrc/Templates/src/templates/maui-blazor-solution/MauiApp.1/Components/_Imports.razorsrc/Templates/src/templates/maui-blazor-solution/MauiApp.1/MainPage.xamlsrc/Templates/src/templates/maui-blazor-solution/MauiApp.1/wwwroot/index.htmlsrc/Templates/src/templates/maui-blazor/Components/_Imports.razorsrc/Templates/src/templates/maui-blazor/MainPage.xaml
New files (not reverted):
src/BlazorWebView/src/Maui/BlazorWebViewFileProvider.cssrc/BlazorWebView/src/Maui/HybridHostPageRenderer.cssrc/BlazorWebView/src/Maui/StaticWebAssetsManifest.cssrc/Templates/src/templates/maui-blazor-solution/MauiApp.1/Components/App.razorsrc/Templates/src/templates/maui-blazor/Components/App.razor
📋 Pre-Flight — Context & Validation
PR #36762 Pre-Flight
Snapshot
- PR:
Add BlazorWebView.AppType to render the host page from an App.razor component - Base:
net11.0 - Public head:
7781ad5c3a8ac3fe5149363351d4a49e25ee9091 - Local review commit:
f484a64bfed6395b642fcdf796d7f06ccd3a8725 - Platform: iOS
- Gate: Inconclusive because the prior gate could not build or run the tests. STEP 5a must not rerun gate verification or modify
gate/content.md. - Linked issue: None identified; this is a feature PR.
Problem
Add BlazorWebView.AppType so a Blazor Hybrid app can render its complete host HTML document from a Razor component without a physical HostPage or an explicit RootComponent. Interactive render-mode boundaries must become live hybrid root components, HeadOutlet must update the document head, and @Assets must resolve and serve fingerprinted static web assets. Existing HostPage behavior must remain unchanged when AppType is not used.
Existing PR Approach
The PR adds a public AppType property and handler mapping. It statically renders the component through a StaticHtmlRenderer subclass, replaces interactive render-mode boundaries with mount placeholders and selector registrations, overlays the generated document through an IFileProvider, and generates and loads a deterministic build-time manifest for fingerprinted assets. It also updates both Blazor templates to use App.razor.
The materialized diff contains 25 files: runtime/build/public-API changes, device tests, and template migrations. New production files include BlazorWebViewFileProvider.cs, HybridHostPageRenderer.cs, and StaticWebAssetsManifest.cs.
Scoped Validation
Use only:
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project BlazorWebView -Platform ios -TestFilter "Category=BlazorWebView" -IncludeClasses "Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements.BlazorWebViewTests" -IncludeMethods "AppTypeRendersHostDocumentAndAttachesComponent,AppTypeSupportsDynamicHeadViaHeadOutlet,AppTypeResolvesFingerprintedAssetsViaAssets"- Primary test:
AppTypeRendersHostDocumentAndAttachesComponent - Mandatory regressions:
AppTypeSupportsDynamicHeadViaHeadOutlet,AppTypeResolvesFingerprintedAssetsViaAssets - Do not run the full device-test project or any repository-wide suite.
Attempt Constraints
- Candidate 1 uses
claude-opus-5; candidate 2 usesgpt-5.6-sol. - Each candidate invokes
try-fixexactly once, explores one mechanism-level alternative, and gets one implementation/test pass plus at most one focused correction/retest. - Candidate 2 must consume candidate 1's recorded result and must not reopen or rerun it.
.github/.baseline-state.jsonis authoritative. OnlyRevertedFilesmay be edited.- If baseline state is absent or
NewFilesis non-empty, recordBlockedbefore editing. - Preserve the pre-existing harness-owned changes under
.github/andeng/. - Restore only with
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore. - Perform the expert self-review inline as required by
try-fix; do not launch a separate expert or reviewer agent.
🔬 Code Review — Deep Analysis
Expert PR Evaluation — PR #36762
Verdict: NEEDS_CHANGES
Confidence: High for the verified code-level findings; template-conditional behavior remains unverified.
Independent assessment
The submitted fix introduces a coherent opt-in AppType path: it statically renders a full-document Razor component, converts interactive render-mode boundaries into hybrid root registrations, overlays the generated host document through an IFileProvider, and reconstructs fingerprinted static-web-asset metadata at build time. The existing HostPage path remains unchanged when AppType is null.
The implementation is not ready as submitted because startup rendering is a side effect of a synchronous, public virtual file-provider factory and its state is guarded by a one-way latch. The expert reviewer identified concrete failure paths involving failed renders, AppType reassignment, multiple interactive roots, UI-thread deadlock, and trimming.
Findings
Blocking
BlazorWebView.cs:200sets_appTypeRenderedbefore rendering succeeds. A render exception leaves the latch set and the cached HTML null, so a later handler reconnect serves no synthetic host page and can remain blank without surfacing the original error.BlazorWebView.cs:76does not invalidate cached HTML or remove generated root registrations whenAppTypechanges. Reassignment continues serving the old document, while settingAppTypeback to null leaves the syntheticHostPagevalue behind.HybridHostPageRenderer.cs:125assigns every non-head interactive boundary the sameid="app"and#appselector. Two boundaries therefore target the same DOM node.HybridHostPageRenderer.cs:104synchronously waits forQuiescenceTaskwhile startup runs on the UI thread. Host component initialization that awaits main-thread work can deadlock startup.BlazorWebView.cs:191suppresses IL2072 on a reachable path instead of preserving the component type through aDynamicallyAccessedMembers(All)contract onAppType.
Additional actionable issues
MapAppTypeis added as public API even though its initial mapper invocation is redundant afterMapHostPage, and laterAppTypeassignments do not notify the handler.- Rendering and root-registration mutation inside overridable
CreateFileProvidermeans an existing override that does not callbasesilently disables the feature. - The synthetic host file reports a changing
LastModifiedvalue, and its host-path comparison is inconsistent with the manifest lookup. - A derived
HeadOutletis not recognized; unsupported render modes do not produce a feature-specific diagnostic. - Static asset manifest loading performs synchronous package I/O and swallows malformed-manifest errors without logging.
- The MSBuild task has minor robustness and unconditional-packaging concerns.
- The new Razor template conditional syntax requires template-instantiation verification.
The raw right-side inline findings are persisted in ../inline-findings.json as 16 GitHub Review API comments.
Gate and prior evidence
The trusted gate is INCONCLUSIVE because XHarness did not produce a fresh result file; this is infrastructure evidence, not a regression failure. The three focused device tests did not run. STEP 5a produced two design-only alternatives, but both were blocked before edits and validation, so neither supplies an executable competitor or contradictory test evidence.
Reviewer-guided candidate
A consolidated pr-plus-reviewer candidate should address the verified lifecycle, multi-root, trim/AOT, file metadata, and diagnostic defects without redesigning the entire feature. The UI-thread/quiescence deadlock and side-effecting factory boundary require special care; if they cannot be removed safely in one bounded patch, they must remain explicit unresolved risks in the comparison.
🛠️ Try-Fix — Analysis & Comparison
Try-Fix Aggregate — PR #36762 (BlazorWebView.AppType)
STEP 5a bounded alternative-fix exploration is complete. Exactly two candidates were attempted,
and both were Blocked before edits. No candidate was empirically validated.
Status / Comparison Table
| Candidate | Model lane | Approach (one line) | Files changed | Result | findings_count | Test executions | Primary test AppTypeRendersHostDocumentAndAttachesComponent |
Restoration |
|---|---|---|---|---|---|---|---|---|
| 1 | claude-opus-5 |
Marker-comment root discovery + handler-startup materialization + build-time fingerprint materialization (designed, not applied) | None (0 files) | ❌ Blocked | 0 ([]) |
0 of 2 | NOT RUN | -Restore no-state path (Restored False); worktree unaltered |
| 2 | gpt-5.6-sol |
Live document-root renderer + compiled static-web-assets endpoint index (designed, not applied) | None (0 files) | ❌ Blocked | 0 ([]) |
0 of 2 | NOT RUN | -Restore no-state path (Restored False); worktree unaltered |
Blocking cause (Candidate 1): EstablishBrokenBaseline.ps1 refused the pre-existing harness-owned
dirty worktree, so .github/.baseline-state.json was never created (no RevertedFiles allow-list).
Independently, PR #36762 adds three new production files, which is itself a Blocked condition
(non-empty NewFiles) that would persist even on a clean worktree.
Blocking cause (Candidate 2): Its own mandatory baseline command independently failed on the same
pre-existing harness-owned dirty worktree and created no baseline state. The same three added
production files independently imply non-empty NewFiles. Candidate 2 therefore made no edits and
did not execute the focused tests.
Candidate 1 — Full Narrative
- Candidate: 1 of 2 (STEP 5a bounded alternative-fix attempts)
- Attempt directory:
CustomAgentLogsTmp/PRState/36762/PRAgent/try-fix/attempt-1/ - Standalone narrative:
CustomAgentLogsTmp/PRState/36762/PRAgent/try-fix-1/content.md - Branch / commit:
pr-review-36762@f484a64bfed6395b642fcdf796d7f06ccd3a8725 - Platform: iOS
- Result: ❌ Blocked (baseline could not be established; no production file edited)
- findings_count: 0 (
[]— empty diff, nothing to review) - Test-command executions used: 0 of 2 allowed
Approach
Proposed alternative (designed, not applied): marker-comment root discovery + handler-startup
materialization + build-time fingerprint materialization.
The attempt was Blocked at Step 2 before any tracked file could legally be edited, so the mechanism
below is a designed-and-documented alternative, not an implemented-and-tested one. It is recorded in
full so Candidate 2 and the reviewer can consume it without re-deriving it.
Three axes, each replacing a PR mechanism with a different source of truth rather than relocating it:
Axis A — how interactive roots are discovered. The PR derives HybridHostPageRenderer from
StaticHtmlRenderer (with #pragma warning disable BL0006) and overrides
ResolveComponentForRenderMode to intercept interactive boundaries, substituting private
HybridMountPlaceholder / HybridEmptyPlaceholder components and accumulating
HybridRootComponentRegistration entries. The alternative renders with the public HtmlRenderer
and discovers roots from the framework's own emitted <!--Blazor:{...}--> component marker comments,
replacing each marker span with a stable mount element. No derivation, no BL0006 suppression, no
placeholder component types, and — critically — no hand-maintained enumeration of render modes.
Axis B — where AppType is materialized and where registrations land. The PR materializes the
document inside BlazorWebView.CreateFileProvider (a public virtual factory called by platform
code), blocks on Task.Run(...).GetAwaiter().GetResult(), and mutates the public RootComponents
collection as a side effect guarded by a non-volatile bool _appTypeRendered. The alternative moves
materialization into the handler's MapAppType startup mapper — which already owns "the view is ready
to start" — resolving AppType into RootComponents before StartWebViewCoreIfPossible() and passing
the rendered document into the file provider as an immutable constructor argument.
Axis C — how fingerprinted assets resolve. The PR regex-scrapes StaticWebAssetEndpoint metadata
in a RoslynCodeTaskFactory MSBuild task into a bundled _maui/blazor-asset-manifest.json, reads it
at runtime through FileSystem.OpenAppPackageFileAsync under a sync-over-async block, and adds a
fingerprinted-route → physical-path fallback branch inside BlazorWebViewFileProvider.GetFileInfo.
The alternative materializes fingerprinted names as real files under the web root at build time, so
the stock platform file provider serves app.abc123.css with no interception, and supplies
ResourceAssetCollection via DI rather than a protected override Assets on a custom renderer.
StaticWebAssetsManifest's route map and the provider's fingerprint fallback are deleted, not moved.
Prior approach avoided
The only prior approach is the PR's own mechanism; this is attempt-1 and the local try-fix/ tree
contained no earlier attempt-* directories, so there are no earlier try-fix results to avoid. The
PR's three parts (renderer-hook interception, file-provider overlay with public-state mutation, and a
bespoke runtime asset manifest) share one failure mechanism: runtime reconstruction of information
the build system and the framework already possess — render modes enumerated by hand, fingerprint
mappings re-derived by regex, and files re-served that the provider could have served directly. Each
reconstruction is an independent site where an unenumerated or unreadable case degrades silently
instead of failing loudly.
Mechanism-level difference
Discovery moves from imperative interception during render to reading markers the framework already
emits; materialization moves from a provider-factory side effect to the handler startup mapper;
asset resolution moves from runtime manifest lookup plus request rewriting to build-time physical
materialization plus DI. The causal chains:
- Because roots are read from framework-emitted markers instead of an
if (renderMode is A or B or C)
branch, a render mode the PR does not enumerate can no longer fall through to
base.ResolveComponentForRenderModeand render as permanently static, unattached markup. - Because registration completes strictly before web-view startup instead of during provider
construction, the ordering dependency "CreateFileProvidermust run before the manager reads
RootComponents" disappears, and an app overriding thepublic virtual CreateFileProvidercan no
longer silently disableAppType. - Because the fingerprinted file physically exists on disk, serving no longer depends on a runtime
file read succeeding, so a missing/unreadable manifest cannot turn into a 404 for an asset that
@Assetsresolved optimistically.
Unvalidated risk, stated honestly: the alternative was never compiled or tested. Its largest open
risk is Axis A — whether HtmlRenderer emits component marker comments for hybrid-hosted render modes
in the same shape as server-side rendering. If it does not, Axis A would fall back to the PR's
interception hook, while Axes B and C remain independently valid.
Candidate diff
EMPTY. No production, build, API, template, or test file was modified. git diff -- src/ produced
zero bytes; fix.diff and reviewer-findings.diff are both 0-byte files reflecting that empty diff.
(empty diff — no files changed)
The only files this attempt created are its own artifacts under
CustomAgentLogsTmp/PRState/36762/PRAgent/. Pre-existing harness-owned tracked changes under
.github/ and eng/ were left exactly as found and are not part of this attempt's diff.
Files changed
None. (Zero files edited — the attempt was Blocked before Step 5.)
Why Blocked
Step 2 ran the mandated baseline command and it failed:
pwsh .github/scripts/EstablishBrokenBaseline.ps1
...
Exception: .github/scripts/EstablishBrokenBaseline.ps1:392
EstablishBrokenBaseline.ps1 failed: Working directory is not clean.
Clean up before establishing baseline.
The worktree carries ≈49 pre-existing, harness-owned tracked modifications (≈44 under .github/scripts
and .github/skills, plus 5 under eng/scripts). The script's only suggested remedies are committing
them or git checkout -- .; both are explicitly forbidden by this attempt's safety facts. The script
exited before writing .github/.baseline-state.json, which was confirmed absent afterwards.
Two independent Blocked triggers therefore apply, and neither may be bypassed:
- Absent baseline state — no
.github/.baseline-state.json, hence noRevertedFilesallow-list.
try-fix Principle 7: absent state file ⇒ reportBlockedbefore editing. - Structurally non-empty
NewFiles— PR #36762 adds three new production files
(BlazorWebViewFileProvider.cs,HybridHostPageRenderer.cs,StaticWebAssetsManifest.cs). Any
non-emptyNewFilesis itself a Blocked condition because the restore script cannot safely restore
added production files. This trigger is a property of the PR's file set, not of the dirty
worktree — so retrying on a clean tree would still produceBlocked.
Test results
The allowed validation command was not executed (0 of 2 permitted executions used), because there
was no candidate fix to validate:
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project BlazorWebView -Platform ios \
-TestFilter "Category=BlazorWebView" \
-IncludeClasses "Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements.BlazorWebViewTests" \
-IncludeMethods "AppTypeRendersHostDocumentAndAttachesComponent,AppTypeSupportsDynamicHeadViaHeadOutlet,AppTypeResolvesFingerprintedAssetsViaAssets"
| Test | Outcome |
|---|---|
AppTypeRendersHostDocumentAndAttachesComponent (primary) |
NOT RUN |
AppTypeSupportsDynamicHeadViaHeadOutlet (regression) |
NOT RUN |
AppTypeResolvesFingerprintedAssetsViaAssets (regression) |
NOT RUN |
Output summary: test-output.log records that no test command ran, names the exact unexecuted command,
and cites the baseline failure line as the blocking evidence. Gate verification was not rerun and
gate/content.md was not created, modified, or overwritten. The supplied INCONCLUSIVE gate result was
treated as inconclusive, not as a failing fix.
Failure analysis
This is an environment/boundary block, not a failed fix hypothesis. No conclusion about the PR's
correctness, or about the proposed alternative's viability, may be drawn from it.
Insights worth carrying forward:
- The block is deterministic and will recur for any try-fix invocation in this worktree.
EstablishBrokenBaseline.ps1already excludes.github/*and*.mdfrom fix-file detection via
$script:TestPathPatterns, but its cleanliness guard (line ~392) checks the whole worktree — so
files the script would never revert nonetheless block it. That asymmetry is the actual upstream
defect; scoping the guard to non-excluded paths would unblock trigger 1. - Trigger 2 is unfixable within try-fix's model. The revert-to-broken-baseline design assumes the
fix modifies existing files. A feature PR that adds new production files is structurally
unreachable, so mechanism-level alternatives to this PR cannot be empirically validated by this
skill as specified. Such PRs need a different validation lane. - Static-read observations about the PR that stand without running tests (surfaced while designing
the alternative; offered to the reviewer, not asserted as test-verified defects):BlazorWebView.CreateFileProviderispublic virtualand now mutates the publicRootComponents
collection as a side effect, guarded by a plain non-volatilebool _appTypeRendered. An app that
overrides this documented extension point silently disablesAppType, and the render is never
re-run ifAppTypeis reassigned.HybridHostPageRenderer.ResolveComponentForRenderModeenumerates exactly three render modes
(InteractiveServerRenderMode,InteractiveWebAssemblyRenderMode,InteractiveAutoRenderMode).
Any otherIComponentRenderModefalls through tobase, yielding a permanently static,
never-attached component with no diagnostic.StaticWebAssetsManifest.TryLoad()swallows failures and returnsnull, degrading@Assetsto
unfingerprinted URLs; combined with fingerprinted-only physical files this converts a manifest
read failure into a runtime 404 rather than a clear error.
Self-review (Step 6 / Step 7.5)
Performed inline against .github/agents/maui-expert-reviewer.md; no sub-agent, expert reviewer,
rubber-duck, or follow-up agent was spawned. With a zero-hunk diff, the Overarching Principles and the
always-active dimensions (Logic and Correctness, Regression Prevention, Complexity Reduction) are
vacuously satisfied and no routed dimensions apply.
reviewer-findings.json=[]reviewer-findings.diff= 0-byte snapshot of the empty diff- Step 7.5 drift check: no code changed between Step 6 and Step 8, so no refresh was required.
- findings_count: 0
Restoration outcome
Restored with the only permitted command, pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore,
which reported No baseline state found. Nothing to restore. / Restored False. That is the expected
and accepted completion for this verified no-state, no-edit path (try-fix Step 9 exception), and
git diff -- src/ afterwards is 0 bytes, confirming no attempt-created source changes. No
git checkout, git clean, git restore, git reset, git stash, rm, or commit was used at any
point. Pre-existing harness-owned tracked changes under .github/ and eng/, and all pre-existing
untracked paths, remain exactly as found.
Result summary
| Field | Value |
|---|---|
approach |
Marker-comment root discovery + handler-startup materialization + build-time fingerprint materialization (designed, not applied) |
files_changed |
None |
result |
Blocked |
findings_count |
0 |
test executions used |
0 of 2 |
restoration |
EstablishBrokenBaseline.ps1 -Restore → Restored False (no-state path); worktree unaltered |
Candidate 2 — Full Narrative
- Candidate: 2 of 2 (SECOND AND FINAL bounded STEP 5a alternative)
- Attempt directory:
CustomAgentLogsTmp/PRState/36762/PRAgent/try-fix/attempt-2/ - Standalone narrative:
CustomAgentLogsTmp/PRState/36762/PRAgent/try-fix-2/content.md - Branch / commit:
pr-review-36762@f484a64bfed6395b642fcdf796d7f06ccd3a8725 - Platform: iOS
- Result: ❌ Blocked (baseline could not be established; no production file edited)
- findings_count: 0 (
[]— empty candidate diff) - Test-command executions used: 0 of 2 allowed
Approach
Proposed alternative (designed, not applied): a live document-root renderer with a compiled
static-web-assets endpoint index.
The root-cause hypothesis is that the PR creates unnecessary reconstruction work by treating
AppType as a disposable static document factory. Instead, AppType would be the application's
single live root:
- The platform WebView would load a minimal immutable framework bootstrap directly, not a
syntheticHostPageand not an in-memoryIFileProvideroverlay. - A document-root mode in the normal interactive renderer would map the component's
<html>,
<head>, and<body>frames to the real document nodes. Because the whole component tree is
already interactive, nested@rendermodeboundaries would be treated as an in-process no-op
policy and remain in that renderer. No boundary discovery, placeholder component, selector
registration, or second render would occur.HeadOutletwould update the same live<head>. - The build would generate strongly typed C# from
@(StaticWebAssetEndpoint)and compile its
ResourceAssetCollectionplus route map into the app assembly. The renderer would receive that
collection directly, and the central WebView content resolver would translate a fingerprinted
route to its already-packaged logical asset before normal lookup. There would be no JSON
package-file read, wrapper file provider, or fingerprinted physical copy. AppType == nullwould retain the existingHostPagestartup and file-provider path unchanged.
This is a design record only. The attempt was Blocked at the baseline boundary before implementation.
Every prior approach avoided
Existing PR mechanism
The PR subclasses StaticHtmlRenderer, overrides ResolveComponentForRenderMode, replaces
interactive boundaries with HybridMountPlaceholder / HybridEmptyPlaceholder, and appends
separate RootComponents registrations. It synchronously materializes the host as a side effect of
CreateFileProvider, overlays that HTML through BlazorWebViewFileProvider, and generates then
loads _maui/blazor-asset-manifest.json to resolve and serve fingerprinted routes.
Avoided: Candidate 2 does not statically serialize AppType, intercept boundaries, create
placeholders, mutate RootComponents, synthesize a provider-host page, wrap IFileProvider, or
load a runtime manifest.
Candidate 1 mechanism
Candidate 1 designed public HtmlRenderer plus framework marker-comment parsing for root
discovery, MapAppType pre-start materialization and registration, and build-time physical
fingerprint copies with DI-supplied ResourceAssetCollection.
Avoided: Candidate 2 uses no marker comments, mapper-time pre-start materialization, physical
fingerprint copies, or DI asset handoff. Candidate 1 was consumed only from its persisted narrative;
it was not reopened, rerun, changed, corrected, or retested.
Mechanism-level difference
Both earlier designs begin with a static document and then reconstruct live islands. Candidate 2
removes that split: because AppType remains the one live render tree, element frames update the
actual document and render-mode boundaries never need discovery or re-registration. Head changes
therefore flow through the same renderer instead of an independently attached HeadOutlet.
For assets, the PR's runtime JSON and provider fallback and Candidate 1's copied files are replaced
by compiled endpoint metadata plus translation in the existing content-resolution pipeline.
Consequently asset metadata is available before the first render without package I/O, and a
fingerprinted request reaches the packaged logical asset without creating another file or wrapping
the provider.
Candidate diff
EMPTY. No production, build, API, template, or test file was modified. fix.diff and
reviewer-findings.diff are 0-byte snapshots.
(empty diff — no files changed)Only Candidate 2's log artifacts and narratives under
CustomAgentLogsTmp/PRState/36762/PRAgent/ were created. Harness-owned .github/ and eng/
changes were not altered.
Files changed
None. Zero candidate implementation files were edited.
Exact baseline result and failure analysis
The mandatory command ran once:
pwsh .github/scripts/EstablishBrokenBaseline.ps1
...
Exception: .github/scripts/EstablishBrokenBaseline.ps1:392
EstablishBrokenBaseline.ps1 failed: Working directory is not clean.
Clean up before establishing baseline.
It rejected 44 pre-existing harness-owned tracked paths under .github/ and eng/ and exited 1.
A subsequent read-only check reported BaselineStateExists=False. Two independent mandatory
Blocked conditions apply:
- Absent baseline state: no
.github/.baseline-state.jsonmeans noRevertedFilesedit
allow-list and no safe restoration contract. - Added production files: relative to
origin/net11.0, the PR adds
BlazorWebViewFileProvider.cs,HybridHostPageRenderer.cs, and
StaticWebAssetsManifest.cs. A non-empty prospectiveNewFilesset is independently Blocked.
Neither condition could be bypassed, and safety instructions prohibited cleaning or changing the
harness-owned worktree. This is a baseline/restoration-model block, not a failed candidate
hypothesis. No empirical conclusion can be drawn about Candidate 2.
Exact test results / output summary
The only permitted command was not executed (0 of 2):
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project BlazorWebView -Platform ios -TestFilter "Category=BlazorWebView" -IncludeClasses "Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements.BlazorWebViewTests" -IncludeMethods "AppTypeRendersHostDocumentAndAttachesComponent,AppTypeSupportsDynamicHeadViaHeadOutlet,AppTypeResolvesFingerprintedAssetsViaAssets"
| Test | Outcome |
|---|---|
AppTypeRendersHostDocumentAndAttachesComponent (primary) |
NOT RUN |
AppTypeSupportsDynamicHeadViaHeadOutlet (regression) |
NOT RUN |
AppTypeResolvesFingerprintedAssetsViaAssets (regression) |
NOT RUN |
test-output.log records the exact unexecuted command, all three NOT RUN results, and the baseline
evidence. The supplied Gate result remained INCONCLUSIVE; it was not rerun, and
gate/content.md was not created, modified, or overwritten.
Self-review (Steps 6 and 7.5)
Performed inline against .github/agents/maui-expert-reviewer.md, with no child or reviewer agent.
The Overarching Principles and always-active Logic and Correctness, Regression Prevention, and
Complexity Reduction dimensions were applied. With no candidate hunk, no routed dimensions or
concrete finding existed.
reviewer-findings.json:[]reviewer-findings.diff: 0 bytes- Step 7.5: source diff unchanged; review remained current
- findings_count: 0
Restoration outcome
Restoration used the exact and only permitted command:
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore
No baseline state found. Nothing to restore.
Message: No baseline state found
Restored: False
This is the expected accepted no-state outcome because baseline creation failed and no candidate
edit was made. No checkout, clean, restore, reset, stash, commit, deletion, or other cleanup was
used. Pre-existing tracked and untracked harness inputs remain untouched.
Result summary
| Field | Value |
|---|---|
approach |
Live document-root renderer + compiled static-web-assets endpoint index (designed, not applied) |
files_changed |
None |
result |
Blocked |
findings_count |
0 |
test executions used |
0 of 2 |
restoration |
-Restore completed via verified no-state path (Restored False) |
Final STEP 5a conclusion
Exactly two bounded alternatives were attempted. Candidate 1 and Candidate 2 were each independently
Blocked before implementation by absent baseline state, with the PR's three added production files
providing a second structural Blocked condition. Neither candidate executed the focused tests, so
no candidate was empirically validated.
📝 PR Finalize — Recommended Title & Description
Assessment: ✏️ Recommend updating — the current metadata is technically strong, but the description omits the two default-template migrations and overstates the lack of packaging impact for non-AppType apps.
Recommended title
BlazorWebView: Add AppType to render host pages from App.razor
Recommended description
### What this adds
Blazor **Hybrid** apps boot from a static `wwwroot/index.html` (`HostPage="wwwroot/index.html"`), while a Blazor **Web** app boots from an `App.razor` component that renders the whole HTML document. This PR adds an **`AppType`** property to `BlazorWebView` so a hybrid app can boot from that same full-document component — aligning the two models and letting you share (most of) one `App.razor`.
```xml
<BlazorWebView AppType="{x:Type local:App}" />
```
```razor
@* App.razor — the hybrid host document; you author the whole <head> *@
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<link rel="stylesheet" href="@Assets["app.css"]" />
<HeadOutlet @rendermode="InteractiveAuto" />
</head>
<body>
<Routes @rendermode="InteractiveAuto" />
<script src="_framework/blazor.webview.js" autostart="false"></script>
</body>
</html>
```
No `wwwroot/index.html` and no explicit `<RootComponent>` are required.
### Template migration
Both shipped Blazor Hybrid templates now use this model by default:
- `maui-blazor` moves its host document from `wwwroot/index.html` to `Components/App.razor` and sets `BlazorWebView.AppType` from `MainPage.xaml`.
- `maui-blazor-solution` adds the equivalent `Components/App.razor`, removes its physical `wwwroot/index.html`, and sets `AppType` from `MainPage.xaml`.
- Their component imports expose the render-mode names used by the new host component.
This means newly created Blazor Hybrid projects exercise the full-document component path, while existing applications that keep `AppType` unset continue using `HostPage`.
### How it works
Blazor Hybrid has no server endpoint, so it can't render an interactive island inside the document the way a Blazor Web App does — interactivity attaches to an existing DOM element by CSS selector (`WebViewManager.AddRootComponentAsync(type, selector)`). When `AppType` is set:
- The component is **statically rendered** to produce the host document (via a `StaticHtmlRenderer` subclass), so no physical `HostPage` file is needed. The base renderer throws on an interactive `@rendermode`, so we override `ResolveComponentForRenderMode` to intercept it.
- An interactive component with a render mode (e.g. `<Routes @rendermode="InteractiveAuto" />`) is converted into a mount element (`<div id="app">`) plus a selector attach, so no explicit `<RootComponent>` is needed. **Hybrid is always globally interactive**, so the render-mode *value* is only a documentary marker — every supported interactive mode is treated identically (matching the framework's own `WebViewRenderer`, which no-ops render modes).
- `<HeadOutlet @rendermode="…" />` is attached at `head::after` for dynamic `<PageTitle>` / `<HeadContent>`.
The rendered document is overlaid onto the platform file provider at the host-page path, so every platform — including Windows request interception, which consults the file provider for the host page — serves it uniformly with no per-platform changes. The overlay is opt-in via `AppType`; the existing `HostPage` (`index.html`) runtime path is untouched when `AppType` is not set.
### What of a Blazor Web App's `App.razor` works in hybrid
A shared `App.razor` is ~90% identical between web and hybrid. This table maps every element you'd find in the default Blazor Web App `App.razor` to its hybrid behavior:
| `App.razor` element | Hybrid | Notes |
|---|---|---|
| Full `<html>`/`<head>`/`<body>` authoring (title, meta, css) | ✅ | rendered statically as the host document |
| `@Assets["…"]` fingerprinting (css, js, images) | ✅ | resolved MAUI-side — see below |
| `<HeadOutlet>` + `<PageTitle>` / `<HeadContent>` | ✅ | attached at `head::after` |
| `<Routes @rendermode="…">` (interactive app) | ✅ | converted to a live `#app` mount |
| `@rendermode` value (`InteractiveServer`/`WebAssembly`/`Auto`) | ✅ | documentary only — hybrid is always globally interactive |
| Boot `<script>` | ⚠️ differs | hybrid uses **`blazor.webview.js`**, not `blazor.web.js` |
| `<BasePath />` | ◐ | use a static `<base href="/">` in hybrid; literal `<BasePath/>` needs the type relocation below |
| `<ImportMap />` | ◐ | MAUI can generate the `<script type="importmap">` from our `ResourceAssetCollection`; literal `<ImportMap/>` needs the type relocation below. Low value in hybrid (fingerprinted JS-module URLs are a caching concern, and hybrid serves locally) |
| `<ResourcePreloader />` | ➖ N/A | emits `<link rel=preload>` network fetch-prioritization hints — no benefit for assets served locally from the app bundle |
| Static SSR / streaming / interactive islands | ➖ N/A | require a server + `blazor.web.js` hosting model; hybrid is always interactive and serves locally |
**The only genuine aspnetcore ask is small and non-blocking:** `<ImportMap>`, `<ResourcePreloader>` and `<BasePath>` are public components that live in the server-only `Microsoft.AspNetCore.Components.Endpoints` assembly, so a hybrid project can't *compile* a shared `App.razor` that names them. Relocating/type-forwarding them to a client assembly (`Components`/`.Web`) would make a **byte-identical** shared `App.razor` compile everywhere; hybrid then generates the `<ImportMap>` equivalent and no-ops the two it doesn't need. Nothing here blocks this PR.
### `@Assets` fingerprinting
Fingerprinting works end to end, entirely MAUI-side (no aspnetcore change). The SDK already produces fingerprinted static-web-asset routes; the gap was that nothing bundled that mapping into the app, so at runtime `@Assets` had nothing to resolve against. This PR:
- **Generates a minimal, deterministic manifest** at build time from the `@(StaticWebAssetEndpoint)` items (fingerprinted route → logical label only), written **outside** the web root (`_maui/blazor-asset-manifest.json`) so it's never exposed to the web view. Because it's derived purely from content fingerprints and logical names (no timestamps/paths/RIDs) and emitted sorted, it is **byte-identical across architectures** — required for universal (multi-RID) app bundles to merge.
- **Loads it at runtime** (`StaticWebAssetsManifest`, System.Text.Json source-gen) from the app package via `FileSystem.OpenAppPackageFileAsync`, and builds (i) a `ResourceAssetCollection` so `@Assets["app.css"] → app.<hash>.css` (by overriding the host renderer's `Assets` property — the same one the framework's own renderers populate), and (ii) a route→physical map so the web view serves the physical file for a fingerprinted request (the hybrid equivalent of `MapStaticAssets`).
Runtime manifest loading, asset-route fallback, and the rendered host-document overlay are activated only when `AppType` is set, so the existing `HostPage` runtime path remains unchanged. The build target itself emits and bundles the deterministic manifest whenever static-web-asset endpoints exist; that packaging step is not conditional on the runtime `AppType` value.
### Validation coverage
The PR adds iOS-capable BlazorWebView device-test coverage for rendering and attaching the `AppType` host document, dynamic `<HeadOutlet>` updates, and `@Assets` fingerprint resolution and serving.
🏁 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Winner: pr-plus-reviewer
The reviewer-guided candidate is the strongest available implementation because it preserves the submitted architecture while fixing several verified lifecycle, multi-root, public-API, file-provider, and diagnostic defects. It is not merge-ready: the synchronous host-render path can still deadlock, the trim/AOT annotation chain now emits warnings, and the focused wrapper classified the only validation run as blocked.
Candidate comparison
| Rank | Candidate | Implementation | Regression evidence | Expert assessment |
|---|---|---|---|---|
| 1 | pr-plus-reviewer |
One consolidated 16-file patch on the exact sandbox baseline | Direct device log shows all three required methods passing, but the wrapper rejected the run for missing expected fresh testResults.xml; officially BLOCKED / INCONCLUSIVE |
Fixes the sticky render latch, stale AppType behavior, duplicate #app roots, derived-head/render-mode handling, dead mapper API, unstable file metadata, and silent manifest failures. Still has a UI-thread deadlock risk and IL2078/IL2111 warnings. |
| 2 | pr |
Raw submitted 25-file feature | Trusted gate INCONCLUSIVE; requested tests did not produce a valid result | Expert verdict NEEDS_CHANGES with five blocking code defects and additional moderate/minor findings. |
| 3 | try-fix-1 |
No diff; design-only marker discovery, mapper-time materialization, and build-time fingerprint copies | NOT RUN | Blocked before edits by absent baseline state and added production files. Its marker contract was never established, so it is not an executable candidate. |
| 4 | try-fix-2 |
No diff; design-only live document renderer and compiled asset index | NOT RUN | Blocked before edits for the same reasons. It proposes the largest architectural replacement and has no implementation or empirical evidence. |
No candidate failed a regression test, so the mandatory failed-regression demotion rule does not distinguish them. Neither try-fix-* candidate ran tests. The direct pr-plus-reviewer pass lines are useful positive evidence, but the wrapper's fresh-result failure prevents treating them as a complete targeted validation.
Raw PR expert evaluation
The raw PR's overall approach is coherent and keeps the legacy HostPage runtime path unchanged when AppType is null. The single expert pass nevertheless found:
_appTypeRenderedis latched before rendering, making render failures permanently sticky across reconnects.AppTypereassignment keeps stale HTML/root registrations, and clearing it leaves the synthetic host path behind.- Multiple interactive boundaries share
#appand attach to the same DOM node. - synchronous waiting for
QuiescenceTaskcan deadlock when host initialization awaits MAUI main-thread work; - a reachable IL2072 path is hidden with a broad suppression rather than represented in the trimming contract;
MapAppTypeis redundant public API and cannot observe later plain-CLR-property changes;CreateFileProviderhas blocking rendering and public collection mutation as hidden side effects;- the synthetic file has unstable metadata and inconsistent path handling;
- render-mode, manifest-error, and MSBuild edge cases are weakly diagnosed or handled.
The 16 right-side comments are preserved in inline-findings.json.
Why pr-plus-reviewer wins
Unlike both try-fix designs, it is a materialized diff that built successfully. Unlike the raw PR, it makes failed rendering retryable, prevents unsupported runtime AppType mutation, supports multiple interactive roots, removes a redundant unshipped API, stabilizes host-file behavior, and adds a regression scenario that visibly passed in the device log. These improvements outweigh its remaining issues and make it the best basis for the next revision.
Required follow-up before merge
- Eliminate or explicitly redesign the synchronous UI-thread wait over arbitrary host-component async initialization.
- Complete the trim/AOT data-flow contract so IL2078 and IL2111 are not emitted; do not restore the broad suppression.
- Obtain a wrapper-accepted run of the three focused iOS methods.
- Reconsider the side-effecting
CreateFileProviderboundary or make thebaserequirement explicit and enforceable.
Because the winning changes are not present in the submitted PR HEAD, the recommendation is REQUEST CHANGES.
🧭 Next Steps — reviewer changes required
The reviewer-enhanced candidate identified changes that are not yet in the submitted PR.
Why: pr-plus-reviewer is the strongest implemented candidate because it fixes verified lifecycle, multi-root, public-API, file-provider, and diagnostic defects while preserving the PR's core design. It built successfully and the direct device log shows all three requested methods passing, but wrapper validation remained blocked and trim/AOT plus synchronous-rendering risks still require follow-up.
Address the actionable findings in this review before merging.
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!
What this adds
Blazor Hybrid apps boot from a static
wwwroot/index.html(HostPage="wwwroot/index.html"), while a Blazor Web app boots from anApp.razorcomponent that renders the whole HTML document. This PR adds anAppTypeproperty toBlazorWebViewso a hybrid app can boot from that same full-document component — aligning the two models and letting you share (most of) oneApp.razor.No
wwwroot/index.htmland no explicit<RootComponent>are required.How it works
Blazor Hybrid has no server endpoint, so it can't render an interactive island inside the document the way a Blazor Web App does — interactivity attaches to an existing DOM element by CSS selector (
WebViewManager.AddRootComponentAsync(type, selector)). WhenAppTypeis set:StaticHtmlRenderersubclass), so no physicalHostPagefile is needed. The base renderer throws on an interactive@rendermode, so we overrideResolveComponentForRenderModeto intercept it.<Routes @rendermode="InteractiveAuto" />) is converted into a mount element (<div id="app">) plus a selector attach, so no explicit<RootComponent>is needed. Hybrid is always globally interactive, so the render-mode value is only a documentary marker — every interactive mode is treated identically (matching the framework's ownWebViewRenderer, which no-ops render modes).<HeadOutlet @rendermode="…" />is attached athead::afterfor dynamic<PageTitle>/<HeadContent>.The rendered document is overlaid onto the platform file provider at the host-page path, so every platform — including Windows request interception, which consults the file provider for the host page — serves it uniformly with no per-platform changes. The overlay is opt-in via
AppType; the existingHostPage(index.html) path is untouched.What of a Blazor Web App's
App.razorworks in hybridA shared
App.razoris ~90% identical between web and hybrid. This table maps every element you'd find in the default Blazor Web AppApp.razorto its hybrid behavior:App.razorelement<html>/<head>/<body>authoring (title, meta, css)@Assets["…"]fingerprinting (css, js, images)<HeadOutlet>+<PageTitle>/<HeadContent>head::after<Routes @rendermode="…">(interactive app)#appmount@rendermodevalue (InteractiveServer/WebAssembly/Auto)<script>blazor.webview.js, notblazor.web.js<BasePath /><base href="/">in hybrid; literal<BasePath/>needs the type relocation below<ImportMap /><script type="importmap">from ourResourceAssetCollection; literal<ImportMap/>needs the type relocation below. Low value in hybrid (fingerprinted JS-module URLs are a caching concern, and hybrid serves locally)<ResourcePreloader /><link rel=preload>network fetch-prioritization hints — no benefit for assets served locally from the app bundleblazor.web.jshosting model; hybrid is always interactive and serves locallyThe only genuine aspnetcore ask is small and non-blocking:
<ImportMap>,<ResourcePreloader>and<BasePath>are public components that live in the server-onlyMicrosoft.AspNetCore.Components.Endpointsassembly, so a hybrid project can't compile a sharedApp.razorthat names them. Relocating/type-forwarding them to a client assembly (Components/.Web) would make a byte-identical sharedApp.razorcompile everywhere; hybrid then generates the<ImportMap>equivalent and no-ops the two it doesn't need. Nothing here blocks this PR.@AssetsfingerprintingFingerprinting works end to end, entirely MAUI-side (no aspnetcore change). The SDK already produces fingerprinted static-web-asset routes; the gap was that nothing bundled that mapping into the app, so at runtime
@Assetshad nothing to resolve against. This PR:@(StaticWebAssetEndpoint)items (fingerprinted route → logical label only), written outside the web root (_maui/blazor-asset-manifest.json) so it's never exposed to the web view. Because it's derived purely from content fingerprints and logical names (no timestamps/paths/RIDs) and emitted sorted, it is byte-identical across architectures — required for universal (multi-RID) app bundles to merge.StaticWebAssetsManifest, System.Text.Json source-gen) from the app package viaFileSystem.OpenAppPackageFileAsync, and builds (i) aResourceAssetCollectionso@Assets["app.css"] → app.<hash>.css(by overriding the host renderer'sAssetsproperty — the same one the framework's own renderers populate), and (ii) a route→physical map so the web view serves the physical file for a fingerprinted request (the hybrid equivalent ofMapStaticAssets).Both the fingerprinting overlay and the
AppTypehost document are opt-in and additive, so apps that use neither — and the existingHostPagepath — are unaffected.