.NET 11 Blazor analyzers add five warning-level diagnostics, BL0012 through BL0016, to catch component code that can cause redundant rendering, stale authentication state, incorrect loop callbacks, unreachable JavaScript entry points, or unhandled JavaScript interop failures. Treat the upgrade as a review queue: understand the runtime risk behind each warning, fix the production boundary, and suppress only the rare case in which a reusable library deliberately transfers responsibility to its caller.
This guide gives an upgrade sequence, corrected component examples, a CI policy, and checks that exercise the failure paths. The examples target .NET 11 Preview 7 behavior; prerelease behavior can change before the stable release.
Table of Contents
What changed in .NET 11 Blazor analyzers
ASP.NET Core in .NET 11 Preview 7 ships five new Blazor analyzers. All are enabled by default as warnings:
| Diagnostic | Trigger | Risk |
|---|---|---|
| BL0012 | StateHasChanged() where Blazor already schedules a render | Redundant renders and code that hides the real async boundary |
| BL0013 | GetAuthenticationStateAsync() without observing AuthenticationStateChanged | A component can keep showing a stale user |
| BL0014 | A for counter captured by a closure or RenderFragment | Every callback can observe the final loop value |
| BL0015 | A non-public method marked [JSInvokable] | JavaScript cannot invoke the method |
| BL0016 | JS interop outside a try/catch | Disconnects or JavaScript failures can escape the component |
These diagnostics are not a reason to add a blanket NoWarn. Each points to a different ownership problem: Blazor owns event rendering, an authentication component owns change observation, a closure owns captured values, JavaScript needs a public entry point, and the component that initiates interop normally owns its failure policy.
Upgrade without muting .NET 11 Blazor analyzers
Start with one representative application instead of changing the entire solution at once:
- Pin the .NET 11 SDK used by local development and CI.
- Build in Release configuration and save the five diagnostics as a review list.
- Fix one diagnostic family at a time.
- Exercise the behavior associated with that family.
- Promote the diagnostics to errors only after the baseline is clean.
The project policy can be explicit:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<WarningsAsErrors>
$(WarningsAsErrors);
BL0012;BL0013;BL0014;BL0015;BL0016
</WarningsAsErrors>
</PropertyGroup>
Keep the diagnostic severity visible in .editorconfig while the migration is in progress:
[*.{cs,razor}]
dotnet_diagnostic.BL0012.severity = warning
dotnet_diagnostic.BL0013.severity = warning
dotnet_diagnostic.BL0014.severity = warning
dotnet_diagnostic.BL0015.severity = warning
dotnet_diagnostic.BL0016.severity = warning
The MSBuild WarningsAsErrors list makes these five diagnostics fail the build without converting every third-party or newly introduced warning into an immediate release blocker. If a repository already treats all warnings as errors, retain that stronger policy.
Fix BL0012 at the real render boundary
Blazor already schedules rendering for lifecycle methods, component parameter setters, and event callbacks covered by BL0012. Removing the redundant call is normally the correct fix:
<button disabled="@isSaving" @onclick="SaveAsync">
@(isSaving ? "Saving…" : "Save")
</button>
@code {
private bool isSaving;
private async Task SaveAsync()
{
isSaving = true;
await Task.Yield();
try
{
await Orders.SaveAsync();
}
finally
{
isSaving = false;
}
}
}
The Task.Yield() is intentional. It yields to the renderer so the disabled state can reach the browser before a long asynchronous operation. An extra StateHasChanged() does not itself let the browser paint while the handler remains in synchronous work.
Do not remove every call mechanically. Calls made from a timer, a custom .NET event, or another callback outside Blazor’s normal event pipeline can still require InvokeAsync(StateHasChanged). BL0012 is scoped to contexts where the framework already schedules the render.
Fix BL0013 by owning authentication changes
Calling GetAuthenticationStateAsync() once gives a snapshot. If the user signs in, signs out, refreshes claims, or changes identity while the component stays alive, that snapshot can become stale.
When a component only needs declarative authorization, prefer AuthorizeView or the cascading authentication state. When it must use AuthenticationStateProvider directly, subscribe and unsubscribe in the same component:
@implements IDisposable
@inject AuthenticationStateProvider AuthenticationStateProvider
<p>Signed in as: @displayName</p>
@code {
private string displayName = "anonymous";
private bool disposed;
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged +=
OnAuthenticationStateChanged;
await ApplyAsync(
AuthenticationStateProvider.GetAuthenticationStateAsync());
}
private void OnAuthenticationStateChanged(
Task<AuthenticationState> authenticationStateTask)
{
_ = ApplyAsync(authenticationStateTask);
}
private async Task ApplyAsync(
Task<AuthenticationState> authenticationStateTask)
{
var state = await authenticationStateTask;
if (disposed)
{
return;
}
await InvokeAsync(() =>
{
displayName = state.User.Identity?.Name ?? "anonymous";
StateHasChanged();
});
}
public void Dispose()
{
disposed = true;
AuthenticationStateProvider.AuthenticationStateChanged -=
OnAuthenticationStateChanged;
}
}
StateHasChanged() is valid here because AuthenticationStateChanged is a custom event, not a Blazor UI event. The subscription is also a lifecycle responsibility: failing to unsubscribe can keep the component reachable and invoke it after disposal. If you are setting up Microsoft Entra authentication rather than only fixing this warning, the existing Blazor Server authentication with Microsoft Entra ID guide covers that separate configuration task.
For production code, route failures from the fire-and-forget event handler to the application’s error boundary or logger. Do not hide a failed authentication refresh.
Fix BL0014 and BL0015 at callback boundaries
Copy a loop counter before capturing it
A for loop mutates one counter variable. A lambda or render fragment that captures that variable can observe its final value later. Copy it inside the loop:
@for (var i = 0; i < rows.Count; i++)
{
var rowIndex = i;
var row = rows[rowIndex];
<button @onclick="() => SelectRow(rowIndex)">
Select @row.Name
</button>
}
Click the first, middle, and last buttons during verification. A visual check that only confirms the correct number of buttons will not catch a closure bug.
An alternative is a foreach loop over stable row objects or keys. That can express intent more clearly, but the chosen key still needs to be stable if the collection can reorder.
Make JavaScript entry points public
[JSInvokable] tells the JS interop dispatcher that a method is callable, but the method must also be public:
public sealed class ClipboardBridge
{
[JSInvokable]
public static Task<string> NormalizeAsync(string value)
{
return Task.FromResult(value.Trim());
}
}
After applying the BL0015 code fix, invoke the method from the real JavaScript call site. A clean C# build proves the signature is visible to the analyzer; it does not prove the JavaScript identifier, assembly name, serialization, or deployment artifact is correct.
Keep the callable surface small. A public [JSInvokable] method is an application boundary, so validate incoming values and avoid exposing operations that assume a trusted caller.
Fix BL0016 with an explicit JS interop policy
JavaScript interop can fail because the server circuit disconnected, the browser navigated away, the JavaScript function is missing, the module failed to load, or the call was canceled. The component that starts the call should decide which failures are expected and which are defects:
private async ValueTask DisposeChartAsync()
{
try
{
await JS.InvokeVoidAsync("charts.dispose", chartId);
}
catch (JSDisconnectedException)
{
// The circuit is already gone. There is no browser resource left to clean up.
}
catch (JSException exception)
{
Logger.LogWarning(
exception,
"The chart {ChartId} could not be disposed in the browser.",
chartId);
}
}
Do not add catch (Exception) and continue. That would also swallow programming errors, cancellation mistakes, and failures unrelated to interop. Catch the exceptions for which the component has a deliberate recovery or reporting policy.
A reusable wrapper can legitimately let JS exceptions propagate so its caller decides what to do. The .NET 11 release notes explicitly call out that case. Use a narrow suppression with a justification around only the intentional call:
#pragma warning disable BL0016 // Library contract transfers JS failure handling to the caller.
return jsRuntime.InvokeAsync<TValue>(identifier, arguments);
#pragma warning restore BL0016
Document the exception contract on the wrapper. The caller still needs a catch boundary; moving ownership is not the same as removing it.
Configure CI without creating warning debt
Use the migration as a ratchet:
- Record the baseline count for each BL diagnostic.
- Fix the application project before shared libraries, because the application owns most rendering and failure policies.
- Add
WarningsAsErrorsfor BL0012–BL0016 after the baseline reaches zero. - Require every suppression to include a reason and a small scope.
- Re-run the build on every pull request with the same SDK pinned by the repository.
A minimal CI step is:
dotnet restore
dotnet build --configuration Release --no-restore
dotnet test --configuration Release --no-build
Do not let local builds use a different preview SDK than CI. Pinning the SDK avoids a confusing state in which developers do not see warnings that the build server treats as errors, or see diagnostics whose implementation differs from the build server’s version.
Verify behavior, not only the warning count
Run these checks after the build is clean:
- BL0012: start an asynchronous button action and confirm its busy state appears before the operation completes and clears in a
finallypath. - BL0013: keep the component open while signing in, signing out, and refreshing claims. Confirm the displayed identity updates, then navigate away and confirm the disposed component receives no callback.
- BL0014: trigger callbacks for the first, middle, and last loop items, then reorder the collection and repeat.
- BL0015: invoke the method from the production JavaScript bundle with valid and invalid input.
- BL0016: test a missing JavaScript function, a normal browser-side exception, navigation during a pending call, and an Interactive Server disconnect.
Also search for broad suppressions:
git grep -n -E 'NoWarn|dotnet_diagnostic\.BL001[2-6]|pragma warning.*BL001[2-6]'
Review every match. A repository-wide severity = none, a project-wide NoWarn, or a pragma that spans an entire component defeats the migration.
Production risks and edge cases
The five warnings overlap with production boundaries, so a warning-free build is necessary but not sufficient:
- A BL0012 fix can still leave the page unresponsive if synchronous CPU work occurs before the first
await. - A BL0013 subscription can leak or update a disposed component if the lifecycle is incomplete.
- A BL0014 fix can still select the wrong item when code captures a list index and the list reorders. Prefer a stable key when identity matters.
- A BL0015 fix increases the public interop surface. Validate arguments and keep privileged work behind authorization checks.
- A BL0016 catch can hide real defects if it swallows every exception or omits logging for unexpected JavaScript failures.
These are exactly the places to add focused component or end-to-end tests. Avoid tests that merely assert the analyzer ID disappeared; assert the user-visible state and failure behavior that the diagnostic was protecting.
References
- .NET 11 Preview 7 ASP.NET Core release notes
- BL0012 implementation: unnecessary StateHasChanged calls
- BL0013 implementation: stale authentication-state detection
- BL0014 implementation: for-loop variables captured by closures
- BL0015 implementation: non-public JSInvokable methods
- BL0016 implementation: unguarded JavaScript interop
Found this useful? Support more practical developer content.