-
Notifications
You must be signed in to change notification settings - Fork 10.5k
Description
Is there an existing issue for this?
- I have searched the existing issues
Describe the bug
While upgrading my .NET 9 Blazor Server project to .NET 10 and looking into the new circuit connection/persistence logic, I noticed a weird thing that would happen sometimes after I got back from lunch and a meeting. When returning to my tab (unopened and asleep for about 2.5 hours) the reconnect modal would disappear and return me to my page as normal, but it wouldn't blink as it re-renders the page or anything. What's more, clicking on any interactive elements would do nothing, as if the page was frozen. Depending on how my app was working at the moment, there would sometimes be no errors at all and I couldn't find any immediate programmatic indication it was in this state to address it. Using an interactive element (like buttons) in this state causes a silent "There is no event handler associated with this event." error in the browser console.
Expected Behavior
I believe it should be behaving similar to #64228, either before or after the fix. It should error (perhaps even the same error), and then be addressed by default or by an action I can add to the handleReconnectStateChanged event in ReconnectModal. Since these both seem to have to do with lack of available circuit persistence info, they should both act similarly.
Steps To Reproduce
To reproduce this issue use the templates available in visual studio 2026.
- Start a new project from the visual studio 2026 templates, use the Blazor Web App template. Specific settings I have: framework .NET 10, no authentication, render mode server, interactivity location global, include sample pages.
- Edit Program.cs to reduce the PersistedCircuitInMemoryRetentionPeriod to 10 seconds so we can test without waiting 2 hours. Also enables detailed errors:
using Microsoft.AspNetCore.Components.Server;
builder.Services.Configure<CircuitOptions>(options =>
{
options.DetailedErrors = true;
options.PersistedCircuitInMemoryRetentionPeriod = TimeSpan.FromSeconds(10); //from default 2 hours
});
- Run the app
- In the developer panel console run Blazor.pauseCircuit(). This disconnects and disposes of the circuit, leaving it persisted in memory.
- Wait 10 seconds, (what we put in program.cs). During these 10 seconds the circuit is persisted in server memory.
- Run Blazor.resumeCircuit(). This will not have any persisted circuit info since it has expired.
- Click on the navigation sidebar options and notice they do nothing. If you are already on the counter page, click the button and notice the error in the developer panel console.
Recording.2025-12-02.144022.1.mp4
Exceptions (if any)
Reproducing while running in debug mode automatically breaks on the error occurring internally. Shows during UpdateRootComponents in CircuitHost.cs, giving an error "object reference not set to an instance of an object" because RootComponentOperationBatch operationBatch is null, and therefore can't run RootComponentOperation[] operations = operationBatch.Operations;.
.NET Version
10.0.100
Anything else?
Pausing and unpausing again while in the bugged state throws an error, but I haven't investigated that.
Workarounds thought of for developers in the meantime (both gross):
- JavaScript function that subscribes to the window error events and reloads the page when the user clicks something and gets the specific "There is no event handler associated with this event." error message. This is a bad user experience in addition to being very hacky.
- Call a GetCircuitId .NET function from javascript that returns the current circuit id. In normal operation this will return a good value equal to a hidden input field in the dom, but if the circuit is in the broken state it will be different than the hidden field. Nice in that it isn't as bad of a user experience, but still burdensome to check and I'm not sure if it trips the reload condition in any edge cases or race conditions. There is a race condition on circuit reconnecting so I'm wasting time in there already.
- Create CircuitHandler.cs to store CircuitId so it can be fetched (don't forget to register it as scoped service in program.cs)
public class CircuitHandlerService : CircuitHandler
{
public string CircuitId { get; private set; } = null!;
public CircuitHandlerService()
{
}
public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
{
CircuitId = circuit.Id;
return base.OnCircuitOpenedAsync(circuit, cancellationToken);
}
}
- Create CircuitValidator.cs as a helper to hold the JSInvokable function.
public static class CircuitValidator
{
public static event Func<string>? OnValidate;
[JSInvokable]
public static Task<string> GetServerCircuitId()
{
var id = OnValidate?.Invoke();
return Task.FromResult(id ?? "");
}
}
- Modify MainLayout.razor to make the static function have access to the CircuitId (couldn't figure out the DI). Also include a hidden field to check for the stale/zombie dom.
@implements IDisposable
<input type="hidden" id="circuit-id-holder" value="@(handler?.CircuitId ?? "")" />
// ...
@code {
protected override void OnInitialized()
{
CircuitValidator.OnValidate += HandleValidation;
}
private string HandleValidation()
{
return handler?.CircuitId ?? "";
}
public void Dispose()
{
CircuitValidator.OnValidate -= HandleValidation;
}
}
- Modify ReconnectModal.razor.js to check for this circuit id on every reconnect and reload if it isn't equal to the hidden field.
async function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
/* Waste time to beat the race condition of the circuit starting up on the server. May need more time than this depending on your app */
const circuitId2 = await DotNet.invokeMethodAsync('YOUR PROJECT', 'GetServerCircuitId');
const circuitId3 = await DotNet.invokeMethodAsync('YOUR PROJECT', 'GetServerCircuitId');
const circuitId4 = await DotNet.invokeMethodAsync('YOUR PROJECT', 'GetServerCircuitId');
reconnectModal.close();
const circuitId = await DotNet.invokeMethodAsync('YOUR PROJECT', 'GetServerCircuitId');
const circuitIdHolder = document.getElementById("circuit-id-holder");
if (circuitId != circuitIdHolder?.value) {
location.reload();
}
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}