Custom Blazor Controls

The Blazor portal renders a UiControl tree: a layout area is delivered as one JSON object with an areas map (area key → control) and a data map (values bindings point at), updated via RFC 7396 merge-patches. Every control carries a $type discriminator, and the renderer dispatches on it to a Blazor component.

So extending the portal has exactly two halves — the same two the React renderer has:

  1. Server side — a UiControl subclass registered with the hub, so layout areas can emit it.
  2. Blazor side — a component registered as the view for that control type.

Reach for this last. A control that composes existing controls (Controls.Stack, DataGrid, Controls.Markdown) works in every renderer for free. A custom Blazor view works only in Blazor — the React and MAUI clients render the same tree and will not know your $type. Write one when you genuinely need JS interop or a third-party component library; see Cross-Renderer Authoring.

1. Define the control server-side

A control is a record. It carries data, never rendering:

public record HeatmapControl(object Values) : UiControl<HeatmapControl>(ModuleSetup.ModuleName, ModuleSetup.ApiVersion)
{
    public object? ColorScale { get; init; }
    public HeatmapControl WithColorScale(object scale) => this with { ColorScale = scale };
}

Properties are object? rather than concrete types on purpose: a bound value arrives as a JSON pointer to the data map, not the value itself, and is resolved per render.

2. Write the view

Views derive from BlazorView<TViewModel, TView>, which supplies the hub, the synchronization stream, the area path, theme and data-context cascades, and disposal. Override BindData to wire each control property to a field:

public partial class HeatmapView : BlazorView<HeatmapControl, HeatmapView>
{
    private object? Values { get; set; }
    private object? ColorScale { get; set; }

    protected override void BindData()
    {
        base.BindData();                       // 🚨 never skip — binds Id, Class, Style
        DataBind(ViewModel.Values, x => x.Values);
        DataBind(ViewModel.ColorScale, x => x.ColorScale);
    }
}

DataBind resolves a JSON pointer against the stream and re-renders on change; it takes an optional conversion for values that arrive as JsonElement. Deserialize with Stream.Hub.JsonSerializerOptions, never a fresh JsonSerializerOptions — the hub's instance is the one carrying the $type registry, and without it a polymorphic payload degrades to a raw JsonElement and the view renders empty.

The .razor half is an ordinary component:

@inherits BlazorView<HeatmapControl, HeatmapView>

@if (Values is not null)
{
    <div class="@Class" style="@Style">@* … *@</div>
}

3. Register the pair

One line, on the hub configuration:

public static MessageHubConfiguration AddHeatmap(this MessageHubConfiguration config) =>
    config.WithType(typeof(HeatmapControl))
        .AddViews(layout => layout.WithView<HeatmapControl, HeatmapView>());

🚨 WithType is not optional, and it is needed on every hub the control crosses. The control travels as JSON between the hub that emits it and the hub that renders it; a hub whose TypeRegistry lacks the discriminator hands the renderer an untyped JsonElement instead of a HeatmapControl. The symptom is not an error — the area renders empty, or reports that it cannot be found.

4. Ship it — three ways

Where the view lives How it registers When
Core (MeshWeaver.Blazor) in the portal's own composition platform controls
A compiled view pack (a plain class library) its Add…Views() entry point, called at startup third-party component libraries — MeshWeaver.Blazor.Radzen is the reference
A plugin, at runtime WithPortalConfiguration from the plugin's own hub config a module shipping its own UI

The third is the one that needs explanation. A plugin's assembly is compiled and loaded at NodeType activation, long after the layout client was configured — and the portal hub is a different hub (one per browser circuit), so returning a modified config cannot reach it. The delegate is routed instead:

// A NodeType's `configuration` lambda — it configures THIS node's hub; the portal is elsewhere.
config => config
    .WithType(typeof(HeatmapControl))
    .WithPortalConfiguration(portal => portal
        .WithType(typeof(HeatmapControl))
        .AddViews(layout => layout.WithView<HeatmapControl, HeatmapView>()))

From the portal's side nothing is special — it is the same WithView seam the packs use. See UI Extensibility for the registry behind it.

What to know before shipping a runtime view

Each of these is invisible when it bites, which is why they are listed rather than left to discovery:

Cross-Renderer Authoring · Custom React Controls · Data Binding · Display Controls · UI Extensibility

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.