dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.11k stars 9.91k forks source link

Make Blazor WebAssembly apps fully trimmable #49409

Open danroth27 opened 1 year ago

danroth27 commented 1 year ago

The .NET trimmer statically analyzes your project and removes any unused code. In Blazor we use the .NET trimmer to significantly reduce the publish size of Blazor WebAssembly apps. However, we don't currently use the .NET trimmer to its fullest potential. We currently configure the trimmer in a partial mode, which only trims assemblies that are explicitly marked as trimmable. This typically means that the app assembly itself and many dependencies are not trimmed. This is a conservative setting that optimizes for maintaining functionality over reducing app size.

The default trim mode for the .NET trimmer is to trim everything. This is the mode used by the .NET Native AOT workloads.Analyzers provide warnings if there are code paths in the app that are not safe to trim. Given how sensitive web apps are to download size and load time, it seems like doing full trimming should be the goal for Blazor.

Enabling support for full trimming also is a potential solution to enabling a single project model for Blazor Web Apps that target both server and browser. When you multitarget a project, everything in the project is built for both targets unless you indicated otherwise. This is problematic for browser scenarios because you may potentially bring in dependencies, components, and code into the browser targeted build that are only needed on the server. However, if we could enable full trimming and indicate to the trimmer which components are setup to render on WebAssembly, then the browser build could be aggressively trimmed to remove any unwanted code and dependencies.

Enabling support for full .NET trimming typically means removing any dynamic code path that are based on reflection. We would need to make Blazor apps trimmer friendly by removing this code using techniques like source generators. For example, in Blazor we use reflection as part of the component discovery process and in the render tree builder code generated by the Razor compiler. We're already working on enabling component discovery through source generation. We also have on our backlog to handle component parameters through source generation. There may be other related work items as well.

There is also ecosystem impact of enabling full trimming by default. Many .NET libraries are not setup for trimming and would potentially need to be updated. It will take some time to work through these issues with the Blazor community. Certain features also simply wouldn't work with full trimming. For example, dynamically loading dependencies at runtime that are not known statically. For backwards compatibility, app developers can configure the trimmer to not trim assemblies that are not yet trimmer friendly. We'd need to understand better all the cases that get impacted by full trimming and ensure we provide a reasonable user experience for addressing them.

SteveSandersonMS commented 1 year ago

Enabling support for full trimming also is a potential solution to enabling a single project model for Blazor Web Apps that target both server and browser.

It would be fairer to describe it as a potential aid rather than a solution, since (1) trimming is innately unpredictable - people can't fully reason about what will and won't be retained in general, and (2) it will be extremely common for some large dependencies to be retained even though you know they aren't needed, just because some code you don't control uses it in some branch that you know will never be hit.

SteveSandersonMS commented 1 year ago

There are different milestones on this journey - it might be helpful to distinguish at least the following:

  1. Enabling trimming by default for all libraries you consume, not just System.*
  2. Enabling trimming by default for your application/RCLs

Only item 2 requires guaranteeing that Blazor doesn't use reflection-based discovery, which would be most of the work. Whereas item 1 would likely yield most of the benefit since the vast majority of code inside your app itself needs to be retained, otherwise you wouldn't have put it there.

danroth27 commented 1 year ago

Whereas item 1 would likely yield most of the benefit since the vast majority of code inside your app itself needs to be retained, otherwise you wouldn't have put it there.

There is the case where I have an app with 100 pages but I only want one of them on WebAssembly. The trimmer could remove the other 99 when targeting browser if it had some way of understanding which components are using the WebAssembly render mode.

SteveSandersonMS commented 1 year ago

if it had some way of understanding which components are using the WebAssembly render mode.

That's not possible as long as we have the ability to use @rendermode="..." with an expression evaluated at runtime.

eerhardt commented 1 year ago

since (1) trimming is innately unpredictable - people can't fully reason about what will and won't be retained in general

This isn't true for .NET apps all up. The general rule is: "Is it necessary to make the app work? Then it is retained." The general trimming logic is deterministic.

Now for a Blazor app, answering the question "is it necessary to make the app work?" might not be able to be reasoned about. But the general statement that trimming is innately unpredictable doesn't hold for other .NET apps.

We've made a lot of strides in the trimming warnings (which are ignored on Blazor WASM) to tell developers what code has the potential to break their app. If your app publishes with no warnings, our goal is that your trimmed app behaves the same as your untrimmed app.

SteveSandersonMS commented 1 year ago

This isn't true for .NET apps all up. The general rule is: "Is it necessary to make the app work? Then it is retained." The general trimming logic is deterministic.

I didn't say nondeterministic! It's deterministic as far as a machine is concerned, but low on predictability for human developers. You can't easily keep track of (or even reason about) how your code changes are impacting what will get retained.

We've made a lot of strides in the trimming warnings (which are ignored on Blazor WASM) to tell developers what code has the potential to break their app. If your app publishes with no warnings, our goal is that your trimmed app behaves the same as your untrimmed app.

That's great, and describes avoiding "false positive" removals (if there are no warnings, we probably didn't trim something that is required). For very size-sensitive scenarios like Blazor WebAssembly, the "false negative" cases are an equally big concern - something that the app doesn't need but the trimmer can't identify that. We don't have warnings about those.

SteveSandersonMS commented 1 year ago

There is the case where I have an app with 100 pages but I only want one of them on WebAssembly. The trimmer could remove the other 99 when targeting browser if it had some way of understanding which components are using the WebAssembly render mode.

One other point to add here. I totally appreciate where you're trying to get to - having a single project that just puts all the pages/components together and relies on the build system for figuring out what should and should not be in the WebAssembly build.

However, no matter how much the app is trimmed, that won't change the more immediate issue that the compilation will already have failed if your project tries to compile any components/classes in the WebAssembly build that reference things that can't be referenced (e.g., stuff from the ASP.NET Core shared framework, and probably EF assuming that's strictly excluded from the wasm build). So there already has to be some way to exclude most of the non-WebAssembly-specific code from the original compilation, so the core dev experience still involves separating the two builds more explicitly than relying on the trimmer to do it.

ghost commented 1 year ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

Perksey commented 10 months ago

This issue appeared in this blog post: https://perksey.com/blog/blazor-silkdotnet.html

It is currently a blocker for shipping Blazor support for Silk.NET 2.X (it is likely that Silk.NET 3.0 will work without this however, but there is no release date for this and is still in planning).

Alan-FGR commented 9 months ago

This issue is somewhat blocking us as well.

@Perksey Nice article! As a coincidence I found that on GitHub just a few minutes after you push to the repo because I was searching if somebody else was passing FULL_ES3 in the EmccFlags.

SteveSandersonMS commented 9 months ago

@Alan-FGR Can you clarify what scenario is blocked for you? Understanding exactly what you're trying to do would let us understand more about whether our proposed approaches would help or not.

Alan-FGR commented 9 months ago

@SteveSandersonMS thanks for your inquiry and awesome work you do - big admirer here! :D

What's effectively blocking me is the same issue Dylan describes in his blog post: relying on the trimmer at build time to remove platform-specific code - but maybe this is not something we should be doing anyway?

In my case it wasn't too hard to temporarily workaround that. What's bothering me now is that trimming doesn't seem to be working even when it should. I've decided to make a simpler proof of concept without Blazor and am using all the latest wasm-tools (and .net 8.0.100-rc.2.23502.2), but I've had some very strange results with the trimming-related tags.

Many of the documented configs seem to make no difference, and for some reason when I add

<InvariantGlobalization>true</InvariantGlobalization>
<InvariantTimezone>true</InvariantTimezone>

my dotnet.native.wasm triples in size. I've also tried all documented tags to disable System libs, but can't get rid of the huge System.Private.Xml which I guess could have a reasonable excuse to be there, but the same can't be said for e.g. Microsoft.VisualBasic.Core.

I'm not sure I'm doing something wrong, but I've tried the experimental templates and also a few testing repos online with the same result. The runtime is a much larger download than what I think is reasonable.

This is probably not the right issue for all of that though, and I'll try to find a more appropriate once I get back to this.

I'm publishing my tests here: https://alangamedev.com/dotnet-wasm-gamedev/

Thanks again!

SteveSandersonMS commented 9 months ago

@Alan-FGR Thanks for letting us know. It sounds like the issues that concern you are to do with the underlying runtime rather than Blazor itself. Since those could only be fixed in the runtime, would you be willing to file your issue on https://github.com/dotnet/runtime instead?

Sorry if this seems like bureaucracy! However this issue is specific to the things we need to do in Blazor so to keep our plans clear it's really valuable to distinguish Blazor from the underlying runtime.

Perksey commented 9 months ago

So in my experience I did get a MissingMethodException from Blazor itself, not from the runtime. I can try and unwind the changes I've made to see if I can reproduce later if you think that'd be valuable.

Alan-FGR commented 9 months ago

@SteveSandersonMS Apologies for the delay. That sounds perfectly reasonable and not any more bureaucratic than necessary!

I will play a bit more with the settings, maybe starting from one of these minimal Blazor samples that are supposed to trim down to very reasonable sizes (some by the OP and you ofc ☺) since my goal is to actually get a "3D view" working specifically in Blazor, and I diverged from it as a way to reduce to the minimal reproducible scenario with the assumption that Blazor itself, being built on top of the stuff in wasm-tools, would yield the same results.

Depending on my findings I will open a new issue either here or runtime, after making sure it's not been asked before (very likely in the latter I suppose).

Perksey commented 9 months ago

This was the exception I got with TrimMode full

crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: Arg_NoDefCTor, TriangleWasm.Components.Layout.MainLayout
System.MissingMethodException: Arg_NoDefCTor, TriangleWasm.Components.Layout.MainLayout
   at System.RuntimeType.CreateInstanceMono(Boolean , Boolean )
   at System.RuntimeType.CreateInstanceDefaultCtor(Boolean , Boolean )
   at System.Activator.CreateInstance(Type , Boolean , Boolean )
   at System.Activator.CreateInstance(Type , Boolean )
   at System.Activator.CreateInstance(Type )
   at Microsoft.AspNetCore.Components.DefaultComponentActivator.CreateInstance(Type )
   at Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent(IServiceProvider , Type , IComponentRenderMode , Nullable`1 )
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.InstantiateChildComponentOnFrame(RenderTreeFrame[] , Int32 , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewComponentFrame(DiffContext& , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewSubtree(DiffContext& , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InsertNewFrame(DiffContext& , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.AppendDiffEntriesForRange(DiffContext& , Int32 , Int32 , Int32 , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.ComputeDiff(Renderer , RenderBatchBuilder , Int32 , ArrayRange`1 , ArrayRange`1 )
   at Microsoft.AspNetCore.Components.Rendering.ComponentState.RenderIntoBatch(RenderBatchBuilder , RenderFragment , Exception& )
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.RenderInExistingBatch(RenderQueueEntry )
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessRenderQueue()
BurkusCat commented 9 months ago

In case it is of any value, I get the same exception as @Perksey in my site with full trimming in .NET 7 & now .NET 8. Here is the .NET 8 version deployed on a test environment: https://5becee2e.burkus.pages.dev/

crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: Arg_NoDefCTor, Burkus.Shared.MainLayout
System.MissingMethodException: Arg_NoDefCTor, Burkus.Shared.MainLayout
   at System.RuntimeType.CreateInstanceMono(Boolean , Boolean )
   at System.RuntimeType.CreateInstanceDefaultCtor(Boolean , Boolean )
   at System.Activator.CreateInstance(Type , Boolean , Boolean )
   at System.Activator.CreateInstance(Type , Boolean )
   at System.Activator.CreateInstance(Type )
   at Microsoft.AspNetCore.Components.DefaultComponentActivator.CreateInstance(Type )
   at Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent(IServiceProvider , Type , IComponentRenderMode , Nullable`1 )
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.InstantiateChildComponentOnFrame(RenderTreeFrame[] , Int32 , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewComponentFrame(DiffContext& , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewSubtree(DiffContext& , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InsertNewFrame(DiffContext& , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.AppendDiffEntriesForRange(DiffContext& , Int32 , Int32 , Int32 , Int32 )
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.ComputeDiff(Renderer , RenderBatchBuilder , Int32 , ArrayRange`1 , ArrayRange`1 )
   at Microsoft.AspNetCore.Components.Rendering.ComponentState.RenderIntoBatch(RenderBatchBuilder , RenderFragment , Exception& )
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.RenderInExistingBatch(RenderQueueEntry )
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessRenderQueue()
ghost commented 8 months ago

Thanks for contacting us.

We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s). If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

sajjadarashhh commented 7 months ago

Good news i hpoe this will help .net developers to create useful and fast spa applications using blazor wasm