JavaScript trimming
Butil's bridge script carries over 170 JavaScript modules - every browser API on this site - and almost no app calls all of them. A published app can rebuild that bundle from only the modules it can still reach - tree-shaking the JavaScript, the same way the IL trimming the C# side already gets shakes out the wrappers you never inject. This page is the whole feature: the one switch that turns it on, the three signals it works from, where the properties go, and what it cannot see.
<BitButilTrimScripts>true</BitButilTrimScripts> is all an app writes, and a
Blazor WebAssembly project does not even write that - it is already on there. Everything else on this
page is either a default explained, or an exception: a module reached where nothing static can follow, a
project layout the defaults look at from the wrong angle. Read the first three sections and skip the rest
until a publish surprises you.
One property decides whether a publish tree-shakes bit-butil.js, rebuilding it from the modules the app can still reach. It defaults to true in a Blazor WebAssembly project - the one hosting model where the assembly ILLink trims is the same assembly that calls the served JavaScript - and to false everywhere else, so a Blazor Server app or a Blazor Web App's server project turns it on itself. Two boundaries hold the whole feature in. It is publish-only: a build, and so dotnet run and dotnet watch on top of it, keeps the full bundle, so what you debug is never the trimmed JavaScript. And it is head-only: it takes effect in the project that publishes the app's static web assets and stands down in every Razor class library and MAUI/Blazor Hybrid head that shares the solution.
<!-- A Blazor WebAssembly project: nothing to add, this is already the default -->
<!-- Anywhere else - Blazor Server, a Blazor Web App's server project -->
<PropertyGroup>
<BitButilTrimScripts>true</BitButilTrimScripts>
</PropertyGroup>
<!-- ...and to opt out of it where it is on by default -->
<PropertyGroup>
<BitButilTrimScripts>false</BitButilTrimScripts>
</PropertyGroup>Every chunk in the bundle opens with a guard naming the module it registers, so the bundle is self-describing: fetch it, count the guards, and you have the exact list the browser sees - which is the check to run against your own published app when you want to know whether the trimming did anything. Run it here and it reports on this documentation site, which has a page for every API in the library and does not turn the trimming on, so it is the full set and a baseline rather than a success story. The second button asks the app for one per-module file, which is the other half of the same question: a 200 means this app publishes the per-module files as well as the bundle, and a 404 means it is in plain bundle mode, where the modules/ folder is dropped from its static web assets entirely - a file that cannot be requested is one more thing that cannot 404 in production.
var url = navManager.ToAbsoluteUri("_content/Bit.Butil/bit-butil.js");
var response = await fetch.Send(new FetchRequest { Url = url.ToString(), Cache = "no-store" });
var script = Encoding.UTF8.GetString(response.Body);
// Every chunk opens with `if (window.BitButil && window.BitButil.<key>) return;` - the guard
// that makes loading it twice a no-op. It survives minification, so it is the one marker that
// reads the same in a development build and a published bundle.
const string anchor = "window.BitButil&&window.BitButil.";
var modules = new List<string>();
for (var at = script.IndexOf(anchor); at >= 0; at = script.IndexOf(anchor, at + 1))
{
var start = at + anchor.Length;
var end = start;
while (end < script.Length && (char.IsLetterOrDigit(script[end]) || script[end] == '_')) end++;
// The prelude chunk guards on `version` rather than on a module of its own.
var name = script[start..end];
modules.Add(name == "version" ? "butil" : name);
}Not a Bit.Butil property, and the most precise signal there is. Every interop call in the library goes through a literal string of the form BitButil.<module>.<function>, and ILLink rewrites a trimmed assembly's string heap to hold only the literals that surviving method bodies still reference. So the set of those prefixes left in a trimmed Bit.Butil.dll is exactly the set of JavaScript modules the app can still reach - not an approximation of it. A standalone Blazor WebAssembly app publishes trimmed by default, which is why it needs nothing configured at all. Whenever this signal is in play it supersedes the scan below: the same question, better answered.
<!-- The default for a standalone Blazor WebAssembly publish - nothing to write.
Turning it off is what hands the question to BitButilScriptScan instead. -->
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>An app published without trimming has no trimmed assembly to read, so the question is answered from the app's own assemblies instead: injecting Clipboard is a reference to Bit.Butil.Clipboard, and the package reads the untrimmed Bit.Butil.dll to know which JavaScript module answering that class takes. The closure is followed properly, through base classes and internal interop helpers - LocalStorage carries no interop literals of its own and still correctly pulls in storage, and Window pulls in events as well as window. It defaults to TypeReferences wherever BitButilTrimScripts is on, which is what makes that switch enough on its own, and it costs the publish one pass over the app's assemblies - tens of milliseconds. On this repository's own trimming harness, and on a real WebAssembly app published both ways, it reaches exactly the module set ILLink independently arrives at.
<PropertyGroup>
<BitButilTrimScripts>true</BitButilTrimScripts>
<!-- ...which already implies this. Write it out only to choose a different value: -->
<BitButilScriptScan>TypeReferences</BitButilScriptScan>
</PropertyGroup>TypeReferences is the default and the precise one: it reads each assembly's metadata tables and takes the Bit.Butil types they genuinely reference. TypeNames matches the library's type names against the names in each assembly's string heap instead - cheaper to reason about and deliberately coarser, since an app with a class of its own called Window, Console or Storage pulls in that module too. It over-includes and never misses, so it is the value to reach for if you ever suspect the precise mode of missing something. None turns the scan off: it is how one project publishes the full bundle while BitButilTrimScripts stays true for the rest, and how to silence a scan that cannot see the app. All three are ignored when PublishTrimmed is true.
<PropertyGroup>
<!-- exact: the Bit.Butil types the app's assemblies reference (default) -->
<BitButilScriptScan>TypeReferences</BitButilScriptScan>
<!-- coarser: bare type-name matching, over-includes, never misses -->
<BitButilScriptScan>TypeNames</BitButilScriptScan>
<!-- off: publish the full bundle without turning the switch off -->
<BitButilScriptScan>None</BitButilScriptScan>
</PropertyGroup>An item rather than a property, and always ADDED to what the other two concluded - never used instead of them. It takes JavaScript module names, Bit.Butil class names, or full type names interchangeably, and it is the way to keep a module reached from somewhere nothing static can follow: an API resolved by reflection, or one your own JavaScript calls through window.BitButil directly. It is also a signal in its own right rather than only a supplement to one: with no ILLink and the scan turned off, a csproj that names modules trims to exactly those and nothing else. A name that is neither a module nor a Bit.Butil class fails the build rather than being quietly ignored, because MSBuild accepts a misspelled item without a word and a silently dropped name is a module missing from a published bundle.
<ItemGroup>
<!-- class names, module names and full type names all resolve -->
<BitButilScriptModule Include="Clipboard;geolocation;Bit.Butil.WebAuthn" />
</ItemGroup>// The case this item exists for: nothing static leads from the app's C# to this call, so neither
// ILLink nor the scan can see that the geolocation module is still needed. Without naming it in the
// csproj above, a published app would 404 on the line below - or, in a lazy-scripts app, import a
// module file the publish dropped.
window.showMyLocation = async () => {
const result = await BitButil.geolocation.getCurrentPosition({});
if (result.position) console.log(result.position.coords.latitude, result.position.coords.longitude);
};Butil ships its JavaScript in two shapes built from the same per-module chunks: one bit-butil.js bundle behind a script tag, and one self-contained file per module that the C# side imports on first use. The same signal narrows both. Wherever the per-module files are published - a lazy-scripts app, or one keeping both shapes - only the modules the app can still import are published and the rest of modules/ is dropped, and nothing can 404 over it, because the identifier that would have imported a dropped module left the assembly with the module. The one exception is asking for the modules by name: BitButilIncludeScriptModules written out in the csproj outranks the trimming and publishes every module file, which is what an app that loads them from outside its own interop calls needs.
<PropertyGroup>
<!-- keep both shapes, and publish every module file whatever the trimming concluded -->
<BitButilIncludeScriptBundle>true</BitButilIncludeScriptBundle>
<BitButilIncludeScriptModules>true</BitButilIncludeScriptModules>
</PropertyGroup>Four shapes, and none of them writes more than a single line. A standalone WebAssembly app or PWA publishes trimmed and writes nothing at all. A Blazor Server app, or a Web App's server project, turns the switch on and gets the scan with it. A server host that references a WebAssembly client is the case worth thinking about: the scan covers the host and the client alike, because the client's assembly is one of the host's references, whereas ILLink's answer for such a host is about the host's own code only - which is why trimming there wants the scan rather than PublishTrimmed, and why the switch goes in the host project that serves bit-butil.js. A Blazor Hybrid head is the one shape where none of it applies: it packages its wwwroot at build time rather than through a publish of a web app's static web assets, so it ships the full bundle and reaches for lazy scripts instead.
<!-- Standalone Blazor WebAssembly or PWA: nothing at all. A Release publish is trimmed, so the
switch is already on and ILLink's own answer is the signal. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup><!-- Blazor Server. There is no trimmed assembly to read, so the switch turns on the scan instead. -->
<PropertyGroup>
<BitButilTrimScripts>true</BitButilTrimScripts>
</PropertyGroup><!-- The HOST of a Blazor Web App - the project that serves bit-butil.js, which is what makes it the
one that has to write this. The scan covers the host and the client alike, because the client's
assembly is one of the host's references; ILLink's answer here would be about the host's own
code only, which is why this shape wants the scan rather than PublishTrimmed. -->
<PropertyGroup>
<BitButilTrimScripts>true</BitButilTrimScripts>
</PropertyGroup><!-- Blazor Hybrid: the trimming does not apply at all. A hybrid head packages its wwwroot at build
time rather than through a publish of a web app's static web assets, so it ships the full bundle
and reaches for lazy scripts instead. -->
<PropertyGroup>
<BitButilLazyScripts>true</BitButilLazyScripts>
</PropertyGroup>BitButilScriptScan and BitButilScriptModule describe what the app
calls, so they belong to the one project that publishes the app's static web assets: the WebAssembly
head, or a Blazor Web App's server project. A shared Directory.Build.props looks like the
obvious home and is the one place they should not go - from there they also reach every Razor class
library and every MAUI/Blazor Hybrid head in the solution, and a class library asked what "the app"
calls would answer from its own references, which are not the app's. Butil settles this rather than
trusting it: trimming runs only in a project loaded by the Web SDK or the Blazor WebAssembly SDK -
which is every project that publishes an app, and neither a Razor class library nor a hybrid head -
and anywhere else it stands down, saying so in the build output when
what reached that project describes what the app calls rather than being only the switch. So a shared
props file costs you a message rather than a wrong bundle. BitButilTrimScripts is the
exception worth sharing: it is only a switch, it already defaults to on exactly where it applies, and
the scan it turns on is read per project, so only the project that publishes the app ever acts on one.
Two escape hatches for the layouts where the defaults look in the wrong place, and neither is needed by an ordinary app. The scan reads this project's own assembly plus its copy-local references, which for a head is the app; BitButilScriptScanAssembly replaces that list for an app whose Bit.Butil calls live somewhere the head cannot see. And the scan has to read the untrimmed Bit.Butil.dll itself to know which module answers each Bit.Butil class - it finds it among those same copy-local references, and BitButilUntrimmedAssembly names it outright for a layout where that lookup comes back empty or turns up more than one. A scan you asked for by writing BitButilScriptScan out fails the build when it cannot read that assembly, since there the answer is this property rather than silence; the scan the switch turned on by itself stands down to the full bundle instead, because nobody asked for it.
<ItemGroup>
<BitButilScriptScanAssembly Include="$(OutputPath)MyApp.Shared.dll" />
</ItemGroup>
<PropertyGroup>
<BitButilUntrimmedAssembly>$(NuGetPackageRoot)bit.butil\10.6.1\lib\net10.0\Bit.Butil.dll</BitButilUntrimmedAssembly>
</PropertyGroup>The trimming reports what it concluded at normal verbosity - publish with -v:n and there is a line naming the modules it kept and the bytes it wrote, which is the fastest way to confirm the feature ran at all. Two warnings can come out of it, both carrying a code so they can be silenced through NoWarn without turning the trimming off. BUTIL001 says the app calls a BitButil.<module> that this version of the library does not have: not an error, since that C# call would fail regardless of trimming, but it means the two halves drifted. BUTIL002 says none of the assemblies scanned references Bit.Butil at all - the list was not the app's - and the full bundle is kept rather than trimming to nothing on an answer that cannot be right.
dotnet publish -c Release -v:n
# Bit.Butil: scanned 1 assembly(s) referencing Bit.Butil and found 2 Bit.Butil type(s): Bit.Butil.Clipboard, Bit.Butil.Geolocation.
# Bit.Butil: bit-butil.js trimmed to 4 of 171 modules (4,395 bytes): butil, utils, clipboard, geolocationThe JavaScript half rests on the C# half. Butil is annotated for trimming throughout: service registration discovers classes by reflection but each one carries an attribute that preserves its constructor, and every type crossing the interop boundary is annotated so System.Text.Json keeps the members it reflects over. A trimmed publish therefore keeps only the wrappers the app injects, which is what makes the trimmed assembly a trustworthy answer to which JavaScript it needs. What is yours rather than the library's is your own models: anything you serialise into a Butil call or deserialise out of one has to be reachable by the trimmer, or it comes back with silently null properties rather than an error. The one Butil API that warns under trimming is the FastInvoke* extension method pair on IJSRuntime, for the same reason - see fast invoke on the render-modes page.
// Your payload types, not Butil's: annotate anything you serialise into a Butil
// call or deserialise out of one, or keep it in a project that is not trimmed.
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public class MyPayload
{
public string Name { get; set; } = "";
}Trimming makes the bundle smaller; lazy scripts remove the bundle. The first call into an API imports that API's own module - _content/Bit.Butil/modules/clipboard.js for Clipboard - so the browser downloads the JavaScript for the APIs the app actually calls and nothing else, in every hosting model, trimmed or not, with no script tag anywhere. The two are not alternatives to choose between: a publish with both on trims the module files to the reachable set and then loads them one at a time, which is the smallest shape there is. The cost is one extra request the first time each API is used, and the property belongs in every project that uses Butil - a Blazor Web App's server and client both, since the prerender pass runs in the server's.
<!-- No <script> tag needed anywhere. Combine with the trimming above: the module
files are narrowed to what the app can reach, then loaded on first use. -->
<PropertyGroup>
<BitButilLazyScripts>true</BitButilLazyScripts>
<BitButilTrimScripts>true</BitButilTrimScripts>
</PropertyGroup>Publish-time tree-shaking has no C# counterpart at all, since it happens inside dotnet publish - but the loading half of it does. The registration call takes an options callback carrying the library's runtime toggles: LazyScripts, ScriptModulesPath for when the package's static web assets are served from somewhere else such as a CDN, and FastInvoke. It is the escape hatch for hosts where the csproj is not yours to edit, and it decides only how the scripts load, not what a publish contains. A default publish keeps the bundle and drops the per-module files, so this switch on its own leaves the imports with nothing to fetch - keep the modules with BitButilIncludeScriptModules, which is also the one way of asking for them that outranks the trimming.
builder.Services.AddBitButilServices(options =>
{
options.LazyScripts = true; // or false to insist on the bundle
options.ScriptModulesPath = "/cdn/butil/"; // optional, when served elsewhere
});<!-- The C# switch decides only how the scripts load, not what a publish contains. A default publish
keeps the bundle and drops the per-module files, so LazyScripts on its own leaves the imports
with nothing to fetch. -->
<PropertyGroup>
<BitButilIncludeScriptModules>true</BitButilIncludeScriptModules>
</PropertyGroup>window.BitButil rather than from C#. Both are correct to drop it - there
is genuinely no C# reference to it - and the symptom is
Could not find 'BitButil.something' in 'window' for that one API, in the published app only.
Name it in BitButilScriptModule, which is added to whatever the signals concluded. If you
are not sure that is the cause, set BitButilTrimScripts to false, publish
again and see whether the symptom goes away before changing anything else.
MSBuild reference
<BitButilTrimScripts>true|false</BitButilTrimScripts><PublishTrimmed>true</PublishTrimmed><BitButilScriptScan>TypeReferences|TypeNames|None</BitButilScriptScan><BitButilScriptModule Include="Clipboard;geolocation" /><BitButilScriptScanAssembly Include="...dll" /><BitButilUntrimmedAssembly>...\Bit.Butil.dll</BitButilUntrimmedAssembly><BitButilIncludeScriptBundle>true|false</BitButilIncludeScriptBundle><BitButilIncludeScriptModules>true|false</BitButilIncludeScriptModules><BitButilLazyScripts>true</BitButilLazyScripts>