Troubleshooting
Nine problems account for almost every issue people hit with Butil. Each one below is the symptom you actually see, what is really going on underneath, and the fix.
The bridge script has not loaded. Either the script tag is missing from the host page, or it comes after the Blazor script and the app booted before window.BitButil existed. Put it first. If it is already first and still failing, check the network tab: a 404 on the _content path means static web assets are not being served, which usually means the host project does not reference Bit.Butil (a project reference from the client alone is not enough when a separate project does the hosting). In an app using lazy scripts there is deliberately no script tag and this error means something else - see the import failure below.
<!-- correct order -->
<script src="_content/Bit.Butil/bit-butil.js"></script>
<script src="_framework/blazor.web.js"></script>Only lazy scripts can produce this: the app called an API, the library asked the browser to import that API's module, and the file was not there. Check the failing URL in the network tab. A 404 under _content/Bit.Butil/modules/ in a published app means the module files were not published - the default publish keeps the bundle and drops them, so turning lazy scripts on from C# alone needs BitButilIncludeScriptModules in the csproj as well, which is why the BitButilLazyScripts property is the better switch: it does both. A trimmed publish narrows the modules further - it publishes only the ones the trimmed assembly can still import, which is every one the library asks for on its own, so a module missing there means something outside those calls wanted it: set BitButilIncludeScriptModules to publish all of them. A URL pointing somewhere else entirely means ScriptModulesPath was set to a path the assets are not served from. And in a Blazor Web App the property has to be set in the server project as well as the client, or the prerender pass runs in a project that never turned lazy scripts on.
<!-- the switch that both loads and publishes the modules -->
<PropertyGroup>
<BitButilLazyScripts>true</BitButilLazyScripts>
</PropertyGroup>AddBitButilServices was never called, or it was called in only one of the two containers your app has. A prerendering app instantiates every component twice - once on the server for the prerender pass and once in the browser - so both service collections have to register the same set, or prerendering fails with a missing-service exception before the browser ever sees the page. If this only happens in a published build, the cause is trimming instead: registration discovers services by reflection, so a Butil class reached only through reflection - never through an injection point, a field or a parameter anywhere in your code - is removed from the bundle and is not there to register.
// Client/Program.cs
builder.Services.AddBitButilServices();
// Server/Program.cs - the prerender host needs it too
builder.Services.AddBitButilServices();This is prerendering working as designed. There is no browser during the server pass, so Butil returns a safe default - an empty string, an empty array, false, zero - rather than throwing. Reading in OnInitializedAsync therefore gets you the default, and reading again in the browser gets the real value. Move any read whose answer you branch on into OnAfterRenderAsync guarded by firstRender, and call StateHasChanged afterwards because the first render has already happened.
Bit.Butil.LocalStorage localStorage
<p>_token</p>
{
private string? _token;
// Not OnInitialized: that runs on the server during the prerender pass, where there is no
// browser to ask. Butil answers with the default rather than throwing, which is why the value
// is empty rather than the page being broken.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
_token = await localStorage.GetItem("token");
StateHasChanged();
}
}The callback arrived from JavaScript rather than from Blazor's own event dispatch, so nothing told the renderer that state changed. Mutate your state in the handler and then ask for a re-render through InvokeAsync(StateHasChanged) - InvokeAsync, not StateHasChanged directly, because the callback may not be running on the renderer's synchronization context.
await box.SubscribeEvent<ButilMouseEventArgs>(js, ButilEvents.Click, args =>
{
_clicks++;
InvokeAsync(StateHasChanged);
});Three separate rules produce this, and the message rarely says which. The page may not be a secure context, so the API object does not exist at all. The user may have denied the permission, in which case it stays denied until they clear it in site settings. Or the call may have lost its user gesture - the window closes as soon as the handler yields to something slow, so a permission-gated call has to be the first await in a click handler, not the one after a server round trip.
Bit.Butil.Window window
Bit.Butil.Permissions permissions
{
// Check which of the three it is before guessing:
private async Task Diagnose()
{
var secure = await window.IsSecureContext(); // false → no API at all
var state = await permissions.Query("clipboard-write"); // Denied → user said no
// still failing → the gesture was gone by the time you called
}
}A subscription that is never disposed keeps the JavaScript side holding a reference to your component's delegate, so it fires against a component that is no longer rendered - and the component cannot be collected. Every Butil subscription hands back a token for exactly this reason. Keep it, and dispose it. Watch out for the bulk removers too: Keyboard.RemoveAll clears every shortcut registered against that Keyboard instance, not only the ones your component added.
IAsyncDisposable
{
private ButilSubscription? sub;
public async ValueTask DisposeAsync()
{
if (sub is not null) await sub.DisposeAsync();
}
}Browsers treat localhost as a secure context so that development works over plain HTTP. The moment the app is served from a real host over HTTP, every secure-context API disappears - clipboard, crypto, geolocation, service workers, web locks, storage estimates and more. Serve over HTTPS. The browser-support page lists exactly which APIs are affected, and IsSupported() answers it per API: it probes for the object itself, which an insecure context does not have, so it reports false for exactly the reason you are chasing.
Bit.Butil.Window window
Bit.Butil.Clipboard clipboard
Bit.Butil.Crypto crypto
{
private async Task Check()
{
if (await window.IsSecureContext() is false)
{
// this is the difference between your machine and production
}
// per-API, and the same answer: false here means navigator.clipboard
// does not exist, which over plain HTTP it never does
if (await clipboard.IsSupported() is false) { /* ... */ }
if (await crypto.IsSupported() is false) { /* SubtleCrypto is https-only */ }
}
}A published app rebuilds bit-butil.js from the modules it can still reach, so the JavaScript for an API that neither the trimmer nor BitButilScriptScan could see is genuinely not in the bundle - and the symptom is the missing-bridge error above, for that one API. It is almost always correct: the API really is only reached from code that was trimmed away, or only through reflection, which neither can follow. Name the module - or the Bit.Butil class behind it - in BitButilScriptModule, which is added to whatever they concluded; or set BitButilTrimScripts to false to publish the whole bundle and confirm that is the cause first.
<!-- Keep this one whatever the publish concluded -->
<ItemGroup>
<BitButilScriptModule Include="Clipboard" />
</ItemGroup>
<!-- Or rule trimming out as the cause entirely -->
<PropertyGroup>
<BitButilTrimScripts>false</BitButilTrimScripts>
</PropertyGroup>Start with which project BitButilTrimScripts is on in. It only ever trims in the one that publishes the app's static web assets - the WebAssembly head, or a Blazor Web App's server project - and a shared Directory.Build.props hands it to every Razor class library and every MAUI/Blazor Hybrid head as well, none of which publish anything. That is harmless: the switch is fine to share, and the scan it brings with it is read per project, so only the head acts on it. What is not harmless is the switch being nowhere near the head - it defaults to on in a WebAssembly project and off everywhere else, so a Blazor Server app or a Web App's server project has to turn it on. If it is on in the right project, look for a Bit.Butil message in the build output saying the trimming stood down, which means a scan or a BitButilScriptModule list was written out somewhere that is not the head. Two other explanations: BitButilScriptScan is set to None, which leaves the switch on with nothing to trim against on an untrimmed publish - or you are looking at a build. A build always keeps the full bundle, dotnet run and dotnet watch included, so the comparison to make is between two publishes.
<!-- src/Directory.Build.props - the switch is fine to share, and brings the scan with it -->
<PropertyGroup>
<BitButilTrimScripts>true</BitButilTrimScripts>
</PropertyGroup>
<!-- MyApp.Server.csproj - but what the app CALLS belongs to the head that publishes it -->
<ItemGroup>
<BitButilScriptModule Include="WebAuthn" />
</ItemGroup>Trimming removed a type that only the JSON serializer ever referenced. Butil's own types are annotated and survive, but your models crossing the interop boundary - anything you serialize into a Butil call or deserialize out of one - need to be reachable by the trimmer. Either keep them in a project that is not trimmed, or annotate them so the linker keeps their members.
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public class MyPayload
{
public string Name { get; set; } = "";
}window.BitButil exist in the DevTools console (under lazy scripts it appears only after the
first call, and Object.keys(window.BitButil) is then the list of modules imported so far)?
Is there a browser yet - is the code running after the first render? Is the API even there - what does
IsSupported() say? The browser-support page answers the third
one live for whichever browser you are testing in.