Blazor views in MeshWeaver follow one rule above all others: subscribe, never await. This page explains that rule, shows the canonical binding shapes, and lists the anti-patterns to delete on sight.

The Rule

Blazor lifecycle methods are synchronous. Views subscribe to streams; they never await hub-touching operations.

The Blazor circuit dispatcher and the mesh hub schedulers share the same message-pump infrastructure. When a lifecycle method awaits pathResolver.ResolvePath(...), hub.GetMeshNode(...), meshService.QueryAsync(...), or any bridged IObservable<T>.ToTask(), the dispatcher blocks — waiting for a response that can only arrive through the same dispatcher it just blocked. Under any real load, this deadlocks deterministically.

There is no nuance, no "short helper" exception. The pattern is:

subscribe → store the result in a field → call StateHasChanged

For the full explanation of why await deadlocks hub-touching operations, see Asynchronous Calls.

❌ async/await — Deadlock ✅ Subscribe — Safe Blazor Circuit Dispatcher (single logical thread per circuit) OnParametersSetAsync() await hub.GetMeshNode(path) Dispatcher BLOCKED waiting for hub response Hub response arrives → needs dispatcher to continue 🔴 DEADLOCK Blazor Circuit Dispatcher (single logical thread per circuit) OnParametersSet() — sync stream.Subscribe(onNext) Returns immediately Dispatcher FREE — circuit responsive Stream emits node / data value _field = value InvokeAsync(StateHasChanged) ✅ View re-renders with fresh data Subscription stored as field; released in Dispose()

Blazor circuit binding: await stalls the single-thread dispatcher so the hub response can never be delivered — subscribing synchronously keeps the circuit free and lets the stream push updates via InvokeAsync(StateHasChanged).

The Canonical Shape

Every Blazor view that needs hub data follows this skeleton:

public partial class MyView : ComponentBase, IDisposable
{
    [Inject] private IPathResolver PathResolver { get; set; } = null!;
    [Inject] private IMessageHub Hub { get; set; } = null!;

    [Parameter] public string? Path { get; set; }

    // Local state — populated by Subscribe callbacks, rendered directly by the view.
    private AddressResolution? _resolution;
    private MeshNode? _node;
    private string? _error;
    private bool _isLoading;

    // Disposable subscriptions stored as fields — released on tear-down.
    private IDisposable? _resolveSub;
    private IDisposable? _nodeSub;

    // ✅ SYNCHRONOUS lifecycle method — no async, no await.
    protected override void OnParametersSet()
    {
        if (string.IsNullOrEmpty(Path))
        {
            _isLoading = false;
            return;
        }

        // Cancel any previous in-flight subscription before starting a new one.
        _resolveSub?.Dispose();
        _nodeSub?.Dispose();
        _resolution = null;
        _node = null;
        _isLoading = true;

        // Subscribe — never await. The callback runs on whichever scheduler the
        // resolver completes on; InvokeAsync(StateHasChanged) marshals back to Blazor.
        _resolveSub = PathResolver.ResolvePath(Path)
            .Catch<AddressResolution?, Exception>(ex =>
            {
                _error = ex.Message;
                return Observable.Return<AddressResolution?>(null);
            })
            .Subscribe(resolution =>
            {
                _resolution = resolution;
                if (resolution != null)
                {
                    // Chain the next reactive step — fetch the node.
                    _nodeSub = Hub.GetMeshNode(resolution.Prefix, TimeSpan.FromSeconds(10))
                        .Subscribe(node =>
                        {
                            _node = node;
                            _isLoading = false;
                            InvokeAsync(StateHasChanged);
                        });
                }
                else
                {
                    _error = "Path not found";
                    _isLoading = false;
                    InvokeAsync(StateHasChanged);
                }
            });
    }

    public void Dispose()
    {
        _resolveSub?.Dispose();
        _nodeSub?.Dispose();
    }
}

The view binds directly to _resolution, _node, _error, and _isLoading — not to a Task<T> callback, not via await. The Subscribe callback updates the fields and triggers a re-render.

Lifecycle Method Reference

Lifecycle Correct use Avoid
Initial load (no parameters) OnInitialized (sync) + Subscribe OnInitializedAsync for hub work
Parameter changes OnParametersSet (sync) + Subscribe OnParametersSetAsync for hub work
After render (DOM access) OnAfterRenderAsync — but no hub awaits inside await on any hub round-trip
Click action Sync handler returning Task.CompletedTask + Subscribe async ctx => await myService.DoX()
Disposal Dispose() releases all held subscriptions Leaving _sub un-disposed

OnAfterRenderAsync is the only lifecycle hook it is safe to leave async — but exclusively for DOM-side work (JSRuntime.InvokeAsync, ElementReference.FocusAsync, etc.). Never await a mesh-touching operation inside it.

Live Data Binding — Staying Subscribed for Updates

Path resolution is a one-shot operation: once the path resolves it does not change while the parameters remain the same. For live data — a node whose content updates while the view is on screen — use Hub.GetMeshNodeStream(path) (the shared IMeshNodeStreamCache handle) and keep the subscription open:

private string? _renderedHtml;

protected override void OnParametersSet()
{
    if (string.IsNullOrEmpty(BoundNodePath)) return;

    // Held subscription — disposed in Dispose(). The upstream cache entry
    // is shared with every other reader (and the writer) of this path.
    _renderSub?.Dispose();
    _renderSub = Hub.GetMeshNodeStream(BoundNodePath)
        .Where(node => node != null)
        .Select(node => node!.PreRenderedHtml)
        .DistinctUntilChanged()
        .Subscribe(html =>
        {
            _renderedHtml = html;
            InvokeAsync(StateHasChanged);
        });
}

The view re-renders on every change pushed to the node — ideal for editors, dashboards, and collaborative views where the user sees content live.

Never .Take(1) on a display stream. That snapshots the first value and unsubscribes; the view stops reflecting updates from that point on.

Multi-Source Streams with a Loading Indicator

When a view fans out across several producers and wants to show a spinner until all of them finish, the reactive shape is: each producer emits 0–N values and then completes; the view subscribes once, sets _isInflight = true on entry, and clears it in Finally. The chat-completion orchestrator (ChatCompletionOrchestrator) is the canonical example.

public partial class ChatInputView : ComponentBase, IDisposable
{
    [Inject] private IChatCompletionOrchestrator Completions { get; set; } = null!;

    private bool _isCompletionsInflight;          // Drives the spinner
    private IReadOnlyList<CompletionItem> _items = [];
    private IDisposable? _sub;
    private bool _isDisposed;

    private void RunCompletions(string query)
    {
        _sub?.Dispose();
        SetInflight(true);

        _sub = Completions
            // Returns IObservable<CompletionBatch> directly — no IAsyncEnumerable bridge.
            .GetCompletions(query, currentNamespace: _ns)
            .SelectMany(batch => batch.Items.Select(ToItem))
            .ScanTopN(50, ItemSortComparer)

            // ⬇ DistinctUntilChanged collapses redundant snapshots so a producer that
            //    completes WITHOUT changing the visible top-N doesn't trigger an extra
            //    StateHasChanged. Use a stable key — reference equality fails because
            //    every Scan emits a fresh list instance.
            .DistinctUntilChanged(SnapshotKey)

            // ⬇ Finally fires on normal completion, error, AND early disposal,
            //    so the spinner always clears no matter how the stream ends.
            .Finally(() => SetInflight(false))

            .Subscribe(
                snapshot =>
                {
                    _items = snapshot;
                    if (!_isDisposed) InvokeAsync(StateHasChanged);
                },
                ex => Logger.LogError(ex, "Completions failed"));
    }

    private void SetInflight(bool value)
    {
        if (_isCompletionsInflight == value) return;   // manual DistinctUntilChanged
        _isCompletionsInflight = value;
        if (!_isDisposed) InvokeAsync(StateHasChanged);
    }

    private static string SnapshotKey(IReadOnlyList<CompletionItem> items) =>
        string.Join('', items.Select(i => i.SortKey ?? i.Label ?? ""));

    public void Dispose() { _isDisposed = true; _sub?.Dispose(); }
}
@if (_isCompletionsInflight)
{
    <FluentProgressRing Title="Loading suggestions…" />
}
@foreach (var item in _items) { ... }

Why Each Primitive Was Chosen

Concern Reactive primitive Rationale
Stream lifecycle IObservable<T> end-to-end OnCompleted is the natural "all sources done" signal — no ProducerTracker, no ChannelWriter, no TaskCompletionSource.
In-flight indicator Subscribe sets flag + Finally(() => SetInflight(false)) Symmetric: subscription flips it on; any terminal notification flips it off. Catches normal completion, error, and early dispose.
Redundant updates DistinctUntilChanged(KeySelector) Each Scan emits a fresh list, so reference comparison is meaningless. A stable string key over the item content makes the comparator effective.
Cross-producer merge Observable.Merge(a, b).Concat(Defer(maybeC)) Merge fans out A and B; Concat starts C only after Merge completes; Defer captures accumulated state at C-start time. No locks, no Subjects.

Avoid Bridging IObservable to IAsyncEnumerable

If the orchestrator already returns an IObservable<T>, subscribe to it directly — do not round-trip through IAsyncEnumerable:

// ❌ WRONG — converts to IAsyncEnumerable and back, loses OnCompleted timing,
//    forces an extra Channel hop, and doesn't compose with DistinctUntilChanged.
return Completions.GetCompletionsAsync(query, _ns)
    .ToObservableSequence()
    .ScanTopN(...)
    .Subscribe(...);

// ✅ RIGHT — the orchestrator IS an IObservable; subscribe directly.
return Completions.GetCompletions(query, _ns)
    .ScanTopN(...)
    .DistinctUntilChanged(SnapshotKey)
    .Subscribe(...);

Click Handlers — Sync + Subscribe

Click handlers must return Task.CompletedTask synchronously and compose their work as an observable chain. The async ctx => await ... form awaits hub work and deadlocks for the same reason lifecycle methods do.

// ✅ RIGHT — sync click action, Subscribe inside.
.WithClickAction(ctx =>
{
    // Optimistic immediate feedback.
    ctx.Host.UpdateData("status", "<p>Working…</p>");

    // Read form data via Subscribe — never `await stream.FirstAsync()`.
    ctx.Host.Stream.GetDataStream<Dictionary<string, object?>>("form")
        .Take(1)
        .Subscribe(data =>
        {
            // Composable mesh ops — Subscribe, never bridge to Task.
            myService.DoWork(data!).Subscribe(
                result => ctx.Host.UpdateData("status", $"<p>Done: {result}</p>"),
                ex     => ctx.Host.UpdateData("status", $"<p>Error: {ex.Message}</p>"));
        });

    return Task.CompletedTask;  // ← click handler signature is sync
})

// ❌ WRONG — async click action, awaits hub work, deadlocks.
.WithClickAction(async ctx =>
{
    var data = await ctx.Host.Stream.GetDataStream<...>("form").FirstAsync();
    var result = await myService.DoWorkAsync(data);
    ctx.Host.UpdateData("status", result);
})

Why await Deadlocks the Blazor Circuit

The circuit dispatcher processes UI events sequentially on a single logical thread — the SignalR connection's pump. When a lifecycle method awaits a Task, the dispatcher stalls. If the awaited work needs the dispatcher to make further progress (even indirectly, via a hub round-trip whose response must flow back through the same dispatcher), the system deadlocks.

The symptoms are easy to recognise and hard to pin down without knowing the cause:

The cure is structural: eliminate the await. Tactical workarounds all fail:

Delete the await. Subscribe instead.

Anti-Patterns to Delete on Sight

// ❌ WRONG — Task bridge in lifecycle.
protected override async Task OnInitializedAsync()
{
    var resolution = await PathResolver.ResolvePath(Path).FirstAsync().ToTask();
    // ...
}

// ❌ WRONG — TaskCompletionSource fakes the bridge.
var tcs = new TaskCompletionSource<X>();
PathResolver.ResolvePath(Path).Subscribe(r => tcs.TrySetResult(r));
var resolution = await tcs.Task;     // same deadlock, more typing

// ❌ WRONG — async click handler.
.WithClickAction(async ctx => { await something; })

// ❌ WRONG — Task.Run "fix".
.WithClickAction(ctx =>
{
    _ = Task.Run(async () => { await myService.DoX(); });
    return Task.CompletedTask;
})

// ❌ WRONG — .Take(1) on a display stream.
//    Snapshots the first value and unsubscribes; the view freezes on first data.
Hub.GetMeshNodeStream(path)
    .Take(1).Subscribe(node => { ... });   // fine for a one-shot READ — never for display

Pre-Merge Checklist

Before merging any Blazor view, verify each point:

  1. No async Task OnInitializedAsync / OnParametersSetAsync for hub work. Use sync OnInitialized / OnParametersSet only.
  2. No await anywhere in the file except inside OnAfterRenderAsync for pure DOM-side calls.
  3. No .ToTask(), .FirstOrDefaultAsync(), or .AsTask() on any mesh observable.
  4. No Task.Run or fire-and-forget _ = SomeAsync(). Subscribe instead — exceptions and cancellation flow through the observable chain.
  5. All held IDisposable subscriptions are stored as fields and disposed in Dispose(). Un-disposed subscriptions leak across navigations.
  6. StateHasChanged is wrapped in InvokeAsync(...) when called from a Subscribe callback, which may run on a non-UI thread.
  7. Display streams stay subscribed (no .Take(1)). One-shot reads use Hub.GetMeshNode(path) — also without .Take(1).

When in doubt: write the code with zero awaits and zero Task returns, then verify the view still updates correctly. If it does not, the missing piece is a Subscribe callback + StateHasChanged — never an await.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.