On an interactive server circuit, NavigationManager.NavigateTo(uri) sends the browser a JS call
from the framework's own fire-and-forget task and awaits the acknowledgement. When the circuit's
connection drops first, the circuit is kept for reconnection, so it is not yet permanently
disconnected, and the framework's quiet path for a disconnected circuit does not apply. The
JS-interop call timeout cancels the call a minute later. The framework then logs
Navigation failed when changing the location to … and raises the cancellation as unhandled, which
the circuit host logs as CircuitHost[111] Unhandled exception in circuit and answers by ending the
circuit. The task is the framework's, so no caller can catch it (MeshWeaver#5362).
Measured on 2026-10-23: two recurrences, on two pods, went to /ReinsuranceDemo/Subscribe and
/SST/Subscribe. That is the paywall a gated page redirects an unentitled viewer to
(PluginGate sets the plugin's RedirectOnDenied, and NamedAreaView renders a RedirectControl).
RedirectView issued that redirect from a data emission, at a browser whose connection may already
have dropped. The earlier full-page shapes are covered by links that leave the app
(FullPageNavigation.LeaveTo).
The fix
InCircuitNavigation.NavigateWithin (MeshWeaver.Blazor) asks the browser itself through
Blazor.navigateTo, the browser-side entry of the same navigation, which still consults the
circuit's location-changing handlers. It then classifies how that call ended against the circuit's
own connection record, ICircuitConnection. That record is fed by CircuitConnection, a scoped
CircuitHandler in MeshWeaver.Hosting.Blazor that counts every OnConnectionDown and tracks the
current state.
| The call ended… | and the connection… | logged as |
|---|---|---|
| acknowledged | anything | nothing |
JSDisconnectedException (the circuit is gone) |
anything | Debug |
| cancelled | was down when the call was issued, dropped while it was pending (even if it came back), or is down now | Information, and the navigation is issued again when the connection is restored (see below) |
| cancelled | stayed up the whole time | Error: a live browser did not answer within the call timeout |
| cancelled | is not reported by the host | Error: no evidence of a disconnect |
any other failure (JSException …) |
anything | Error |
None of these ends the circuit.
A lost navigation is issued again on reconnection. The framework used to end the circuit, so a
reconnecting browser reloaded and the redirect ran again. Now the circuit is kept, and a navigation
the browser never received must still reach it. Otherwise a viewer who reconnects stays on a page
that renders nothing but the redirect. The helper waits for ICircuitConnection.Restored (or goes
at once if the connection is already back) and sends the same navigation again, unless it was
superseded first:
- a later navigation was issued through the helper on the same circuit
(
BeginNavigation/LatestNavigationtickets), or - the circuit's location moved, because the viewer followed a link.
A circuit that ends without reconnecting completes Restored, and nothing is sent.
Ticket and send are one navigation turn (ICircuitConnection.NavigationTurn, a plain monitor
over synchronous work only: a turn sends the call and returns, and never awaits the answer). A
fresh navigation takes its ticket and is sent in one turn. A replay checks its ticket and is sent in
one turn. A newer navigation therefore cannot be sent between a replay's check and its send, and the
newest destination always reaches the browser last. A location move made by the viewer comes from
the browser and is not serialized by the turn. The replay reads it as late as it can.
An empty URI means the application base, as it does for NavigationManager.NavigateTo(""). A statically rendered component has no browser to call, so it keeps
the framework's navigation (an HTTP redirect), synchronously.
Routed through it: RedirectView, ButtonView's NavigateToHref and NavigateOnAccepted, and
NavigationService.NavigateTo for an in-app (non-forceLoad) navigation. Other direct
NavigationManager.NavigateTo calls are not migrated yet. They still take the framework's call,
mostly from user clicks where the race window is a single round trip.
Proof
InCircuitNavigationTest (MeshWeaver.Hosting.Blazor.Test):
TheFrameworkNavigation_RaisesACancelledCallAsAnUnhandledCircuitExceptiondrives the framework's ownRemoteNavigationManagerand shows that a cancelled call is raised as unhandled. This is the mechanism.ALostNavigation_IsIssuedAgain_WhenTheConnectionIsRestored,…CameBackBeforeTheTimeout_IsIssuedAgainAtOnce,…SupersededByALaterOne_IsNotIssuedAgain,…WhoseLocationMoved_IsNotIssuedAgainand…OnACircuitThatEnds_IsNotIssuedAgainpin the re-issue and its limits.AReplay_IsNeverSentAfterANewerNavigation_ThatTookItsTicketDuringTheCheckforces the interleaving with a barrier on the replay's check. Without the turn the replay overtakes the newer navigation.AnEmptyUri_NavigatesToTheApplicationBasepins the empty URI.- The drop cases log nothing at Error. The negative controls stay at Error: a cancellation on a connection that stayed up, a cancellation with no connection record, and a failure the browser reports.
BoundRedirectNavigatesTest.AViewerWhoseConnectionDropped_IsNotAnUnhandledCircuitException
(MeshWeaver.Blazor.Views.Test) renders a real RedirectView. It fails when RedirectView calls
NavigationManager.NavigateTo, because the redirect then reaches the framework's navigation and
not the browser.
ARedirectLostToADroppedConnection_ReachesTheBrowserWhenItReconnects renders the same view through
a drop and a reconnection.