-
Notifications
You must be signed in to change notification settings - Fork 2k
Add BlazorWebView.AppType to render the host page from an App.razor component #36762
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: net11.0
Are you sure you want to change the base?
Changes from 11 commits
ca173fd
52f3cee
06ba7d8
39b35f5
6f78016
fb24f61
c46e8d4
645a9e4
1151747
a00137a
7781ad5
c5f0afd
a06e3a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| using System; | ||
| using System.IO; | ||
| using System.Runtime.Versioning; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.AspNetCore.Components.Web; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.FileProviders; | ||
| using Microsoft.Maui; | ||
| using Microsoft.Maui.Controls; | ||
|
|
@@ -50,6 +52,43 @@ public BlazorWebView() | |
| /// </summary> | ||
| public string? HostPage { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The synthetic host page path used when <see cref="AppType"/> renders the host document. | ||
| /// </summary> | ||
| internal const string AppTypeHostPage = "wwwroot/index.html"; | ||
|
|
||
| private Type? _appType; | ||
| private bool _appTypeRendered; | ||
| private string? _renderedHostPageHtml; | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the type of a root component that renders the entire host HTML document (the | ||
| /// hybrid equivalent of a Blazor Web App's <c>App.razor</c>). | ||
| /// <para> | ||
| /// When set, the component is statically rendered to produce the host page, so a physical | ||
| /// <see cref="HostPage"/> file (such as <c>wwwroot/index.html</c>) is not required. Interactive | ||
| /// components declared inside it with a render mode (for example | ||
| /// <c><Routes @rendermode="InteractiveAuto" /></c> or | ||
| /// <c><HeadOutlet @rendermode="InteractiveAuto" /></c>) are automatically attached to the | ||
| /// live document, so an explicit <see cref="RootComponents"/> entry is not required either. | ||
| /// </para> | ||
| /// </summary> | ||
| public Type? AppType | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] Handler Mapper and Property Patterns — the
Reset |
||
| { | ||
| get => _appType; | ||
| set | ||
| { | ||
| _appType = value; | ||
|
|
||
| // 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)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Logic / correctness — asymmetric setter — The setter assigns the synthetic If a caller clears
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct edge case — clearing |
||
| { | ||
| HostPage = AppTypeHostPage; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consequence: At minimum add
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair callout. Note this is consistent with the existing contract: neither
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Handler Mapper and Property Patterns — Setting
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid and a genuinely new angle — the attribute-order sensitivity is a real footgun: because the setter only defaults |
||
| } | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Bindable property for <see cref="StartPath"/>. | ||
| /// </summary> | ||
|
|
@@ -124,7 +163,56 @@ public string StartPath | |
| public virtual IFileProvider CreateFileProvider(string contentRootDir) | ||
| { | ||
| // Call into the platform-specific code to get that platform's asset file provider | ||
| return GetBlazorWebViewHandler().CreateFileProvider(contentRootDir); | ||
| var platformFileProvider = GetBlazorWebViewHandler().CreateFileProvider(contentRootDir); | ||
|
|
||
| // Everything below is opt-in via AppType. For the legacy HostPage (index.html) path, return | ||
| // the platform provider unchanged so existing behaviour - including the handler's own file | ||
| // provider instance - is preserved exactly. | ||
| if (AppType is null) | ||
| { | ||
| return platformFileProvider; | ||
| } | ||
|
|
||
| // Load the bundled static web assets manifest (if present) so that @Assets fingerprinting | ||
| // 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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Performance / lifecycle — For an app with several
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — the manifest is immutable build output, but it's re-read from the app package and re-parsed (via a blocking |
||
|
|
||
| // Render the host document once. This also collects any interactive components declared with | ||
| // a render mode and registers them so they attach to the live document, and resolves @Assets | ||
| // using the manifest. | ||
| EnsureAppTypeRendered(manifest?.Assets); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Architectural Layer Placement — |
||
| var hostPageRelativePath = Path.GetRelativePath(contentRootDir, HostPage!); | ||
|
|
||
| return new BlazorWebViewFileProvider(platformFileProvider, hostPageRelativePath, _renderedHostPageHtml, manifest); | ||
| } | ||
|
|
||
| [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2072", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] Trimming and AOT Compatibility — This
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — I'll annotate
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up after implementing this (commit a06e3a2): I tried annotating
This is the same IL2111/IL2114 outcome the review's own Report section anticipated for the pr-plus-reviewer candidate. So annotation isn't viable here. I kept a narrowly-scoped IL2072 suppression but rewrote the justification to be accurate: the component type is preserved by the XAML compiler (
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] Trimming and AOT Compatibility — this |
||
| Justification = "Blazor components referenced by AppType are preserved by the Razor SDK trimming roots, consistent with RootComponent.ComponentType.")] | ||
| private void EnsureAppTypeRendered(ResourceAssetCollection? assets) | ||
| { | ||
| if (_appTypeRendered || AppType is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| _appTypeRendered = true; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If Set the flag only after a successful render (or store the failure and rethrow it on subsequent calls) so the failure mode stays diagnosable.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and this one is a real latent bug —
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] Logic and Correctness Verification —
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed — and you're right that the template
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] Logic and Correctness — |
||
|
|
||
| var services = Handler?.MauiContext?.Services | ||
| ?? throw new InvalidOperationException($"Cannot render {nameof(AppType)} because no service provider is available."); | ||
|
|
||
| var result = HybridHostPageRenderer.Render(services, AppType, assets); | ||
| _renderedHostPageHtml = result.Html; | ||
|
|
||
| foreach (var registration in result.Registrations) | ||
| { | ||
| RootComponents.Add(new RootComponent | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Logic and Correctness Verification —
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed (pairs with the |
||
| { | ||
| Selector = registration.Selector, | ||
| ComponentType = registration.ComponentType, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| using System; | ||
| using System.IO; | ||
| using System.Text; | ||
| using Microsoft.Extensions.FileProviders; | ||
| using Microsoft.Extensions.Primitives; | ||
|
|
||
| namespace Microsoft.AspNetCore.Components.WebView.Maui | ||
| { | ||
| /// <summary> | ||
| /// Wraps the platform's physical file provider to add hybrid host-page and static web asset | ||
| /// behaviour on top of it: | ||
| /// <list type="bullet"> | ||
| /// <item><description>serves the in-memory rendered host page (the <see cref="BlazorWebView.AppType"/> | ||
| /// document) at the host page path, when provided; and</description></item> | ||
| /// <item><description>resolves fingerprinted request routes (for example <c>app.abc123.css</c>) to | ||
| /// their physical asset files, when a manifest is provided.</description></item> | ||
| /// </list> | ||
| /// All other requests are delegated unchanged, so existing behaviour is preserved. | ||
| /// </summary> | ||
| internal sealed class BlazorWebViewFileProvider : IFileProvider | ||
| { | ||
| private readonly IFileProvider _inner; | ||
| private readonly string? _hostPageRelativePath; | ||
| private readonly byte[]? _hostPageContents; | ||
| private readonly StaticWebAssetsManifest? _manifest; | ||
|
|
||
| public BlazorWebViewFileProvider( | ||
| IFileProvider inner, | ||
| string? hostPageRelativePath, | ||
| string? hostPageHtml, | ||
| StaticWebAssetsManifest? manifest) | ||
| { | ||
| _inner = inner ?? throw new ArgumentNullException(nameof(inner)); | ||
| _manifest = manifest; | ||
|
|
||
| if (hostPageRelativePath is not null && hostPageHtml is not null) | ||
| { | ||
| _hostPageRelativePath = NormalizePath(hostPageRelativePath); | ||
| _hostPageContents = Encoding.UTF8.GetBytes(hostPageHtml); | ||
| } | ||
| } | ||
|
|
||
| public IFileInfo GetFileInfo(string subpath) | ||
| { | ||
| var normalized = NormalizePath(subpath); | ||
|
|
||
| // Serve the rendered host page from memory. | ||
| if (_hostPageContents is not null && | ||
| string.Equals(normalized, _hostPageRelativePath, StringComparison.Ordinal)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Logic and Correctness — the host-page match uses |
||
| { | ||
| return new InMemoryFileInfo(Path.GetFileName(_hostPageRelativePath!), _hostPageContents); | ||
| } | ||
|
|
||
| // If the file exists as requested, serve it directly (preserves existing behaviour). | ||
| var fileInfo = _inner.GetFileInfo(subpath); | ||
| if (fileInfo.Exists) | ||
| { | ||
| return fileInfo; | ||
| } | ||
|
|
||
| // Otherwise, if the request targets a fingerprinted route, serve the physical asset. | ||
| if (_manifest is not null && | ||
| _manifest.TryResolvePhysicalPath(normalized, out var physicalPath)) | ||
| { | ||
| return _inner.GetFileInfo(physicalPath); | ||
| } | ||
|
|
||
| return fileInfo; | ||
| } | ||
|
|
||
| public IDirectoryContents GetDirectoryContents(string subpath) => _inner.GetDirectoryContents(subpath); | ||
|
|
||
| public IChangeToken Watch(string filter) => _inner.Watch(filter); | ||
|
|
||
| private static string NormalizePath(string path) => | ||
| (path ?? string.Empty).Replace('\\', '/').TrimStart('/'); | ||
|
|
||
| private sealed class InMemoryFileInfo : IFileInfo | ||
| { | ||
| private readonly byte[] _contents; | ||
|
|
||
| public InMemoryFileInfo(string name, byte[] contents) | ||
| { | ||
| Name = name; | ||
| _contents = contents; | ||
| } | ||
|
|
||
| public bool Exists => true; | ||
| public long Length => _contents.Length; | ||
| public string? PhysicalPath => null; | ||
| public string Name { get; } | ||
| public DateTimeOffset LastModified => DateTimeOffset.UtcNow; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Logic / correctness —
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed —
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Logic and Correctness — |
||
| public bool IsDirectory => false; | ||
|
|
||
| public Stream CreateReadStream() => new MemoryStream(_contents, writable: false); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ public partial class BlazorWebViewHandler : IBlazorWebViewHandler | |
| public static PropertyMapper<IBlazorWebView, BlazorWebViewHandler> BlazorWebViewMapper = new(ViewMapper) | ||
| { | ||
| [nameof(IBlazorWebView.HostPage)] = MapHostPage, | ||
| [nameof(IBlazorWebView.AppType)] = MapAppType, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Handler Mapper and Property Patterns — registering an |
||
| [nameof(IBlazorWebView.RootComponents)] = MapRootComponents, | ||
| #if WINDOWS | ||
| [nameof(IView.FlowDirection)] = MapFlowDirection, | ||
|
|
@@ -73,6 +74,27 @@ public static void MapHostPage(BlazorWebViewHandler handler, IBlazorWebView webV | |
| #endif | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Maps the <see cref="IBlazorWebView.AppType"/> property to the specified handler. | ||
| /// </summary> | ||
| /// <param name="handler">The <see cref="BlazorWebViewHandler"/>.</param> | ||
| /// <param name="webView">The <see cref="IBlazorWebView"/>.</param> | ||
| public static void MapAppType(BlazorWebViewHandler handler, IBlazorWebView webView) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Public API Surface Design — |
||
| { | ||
| #if !(NETSTANDARD || !PLATFORM) | ||
| // Only views that opt into AppType need this mapper. When AppType is null the legacy | ||
| // HostPage startup path is left completely untouched (MapHostPage already handled it). | ||
| if (webView.AppType is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // AppType provides a synthetic HostPage, so ensure the handler picks it up and attempts startup. | ||
| handler.HostPage = webView.HostPage; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
What it does add is an ordering hazard: it invokes Either delete this mapper (redundant, and it is now permanent public API — see
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The ordering observation is correct in the abstract, but the predicted outcome (startup with zero components → dead Why it works: 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 |
||
| handler.StartWebViewCoreIfPossible(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Handler Mapper and Property Patterns —
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the accurate framing — thanks for correcting the earlier ordering theory. Agreed |
||
| #endif | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Maps the <see cref="IBlazorWebView.RootComponents"/> property to the specified handler. | ||
| /// </summary> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AppTypeis a publicType?with no[DynamicallyAccessedMembers], but it is passed toHybridHostPageRenderer.Render(..., [DynamicallyAccessedMembers(All)] Type appComponentType, ...). The resulting IL2072 is silenced with anUnconditionalSuppressMessage(line 191) instead of propagating the annotation.The suppression justification says the Razor SDK trimming roots preserve these types "consistent with
RootComponent.ComponentType" — butRootComponent.ComponentTypeis 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 viaAppType(never referenced from aRootComponent,@rendermodein 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:
then the
UnconditionalSuppressMessageat line 191 can be dropped.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed — this is the right call. I'll annotate
public Type? AppType(and theIBlazorWebViewdeclaration) with[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]so the trim contract propagates from the public entry point, and drop theUnconditionalSuppressMessageat line 191. You're right that the justification conflated an SDK root with the annotation onRootComponent.ComponentType— propagating the annotation is the correct fix, not silencing it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow-up after implementing this (commit a06e3a2): I tried annotating
AppType/IBlazorWebView.AppTypewith[DynamicallyAccessedMembers(All)]as agreed, but it does not build clean — it produces new trim errors:IL2114on the backing field (a DAM field referenced by a reflectable type), andIL2111on the setter ("method with DAM parameters is accessed via reflection") — becauseAppTypeis set by XAML via reflection (AppType="{x:Type components:App}"), and a DAM requirement on a reflection-set property/parameter isn't satisfiable by the trimmer.This is the same IL2111/IL2114 outcome the review's own Report section anticipated for the pr-plus-reviewer candidate. So annotation isn't viable here. I kept a narrowly-scoped IL2072 suppression but rewrote the justification to be accurate: the component type is preserved by the XAML compiler (
{x:Type}roots it) and by the Razor SDK trimming roots (@rendermode/routable assembly keep the interactive components), and DAM-on-parameter is incompatible with XAML's reflection-based property setting. The old, incorrect "consistent with RootComponent.ComponentType" wording is gone. Build is clean with trimming enabled (illink runs, 0 warnings).