Custom React Controls

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

Extending the renderer therefore has exactly two halves:

  1. Server side — a UiControl subclass registered with the hub, so your layout areas can emit it.
  2. React side — a component registered for that $type in the renderer's control registry.

Note — the code on this page does not execute in the portal. The live --render blocks used elsewhere in these docs run C# in the portal's kernel; React/TSX cannot execute there. The snippets below are plain fenced code, verified against the @meshweaver/react sources.

1. Define the control server-side

A custom control is a record deriving from UiControl<TControl>, exactly like the built-in ones. The two constructor arguments name the client module and its API version. This is the same pattern the shipped MapControl uses (src/MeshWeaver.Maps):

using MeshWeaver.Layout;

namespace Acme.Widgets;

/// <summary>A tiny inline trend chart. Wire $type: "Sparkline".</summary>
public record SparklineControl() : UiControl<SparklineControl>("Acme.Widgets", "1.0.0")
{
    /// <summary>The numeric series to plot.</summary>
    public IReadOnlyCollection<double>? Data { get; init; }

    /// <summary>Stroke color (CSS color string).</summary>
    public string? Stroke { get; init; }
}

Register the type on every hub that serializes it, and — only if the Blazor portal should also render it — pair it with a Blazor view:

public static MessageHubConfiguration AddSparkline(this MessageHubConfiguration configuration) =>
    configuration
        .WithTypes(typeof(SparklineControl))   // $type discriminator registration
        // Blazor-only: the React client needs no server-side view registration —
        // it resolves the $type in its own registry (next section).
        .AddViews(registry => registry.WithView<SparklineControl, SparklineView>());

Any layout area can now return it:

public static UiControl Trend(LayoutAreaHost host, RenderingContext _) =>
    Controls.Stack
        .WithView(Controls.Title("Weekly volume", 2))
        .WithView(new SparklineControl { Data = [3, 5, 4, 9, 7, 12], Stroke = "#1e88e5" });

On the wire the control appears with its $type discriminator — the class name minus the Control suffix (SparklineControl"Sparkline", the same rule that maps LabelControl"Label" and DataGridControl"DataGrid"):

{
  "areas": {
    "Trend/2": {
      "$type": "Sparkline",
      "data": [3, 5, 4, 9, 7, 12],
      "stroke": "#1e88e5",
      "moduleName": "Acme.Widgets",
      "apiVersion": "1.0.0",
      "skins": []
    }
  }
}

2. Register a React component for the $type

The renderer's dispatch is data-driven: ControlRenderer looks the control's $type up in the active leaf pack — a plain record of maps supplied through RegistryProvider:

// from @meshweaver/react (render/registryContext.tsx)
export type ControlComponent = (props: { control: UiControl }) => ReactNode;
export type SkinComponent = (props: { skin: Skin; control: UiControl }) => ReactNode;

export interface LeafPack {
  controls: Record<string, ControlComponent>;  // $type → component (leaf controls)
  skins: Record<string, SkinComponent>;        // skin $type → wrapper (Stack/Tabs/Card/…)
  fallback: ControlComponent;                  // renders an unknown $type
  defaultContainer: SkinComponent;             // container with no remaining skin
}

The shipped Fluent UI pack exports its registry as a spreadable object — "Spread your own entries to extend or override" is the designed extension point:

import {
  MeshAreaView, RegistryProvider, RenderArea, ScopeProvider,
  controlRegistry, fluentPack, useResolve,
  type ControlComponent, type LeafPack, type UiControl,
} from "@meshweaver/react";

/** The React component for $type "Sparkline". */
const SparklineView: ControlComponent = ({ control }: { control: UiControl }) => {
  // useResolve handles both literal values and bound /data pointers —
  // the same resolution every built-in control uses.
  const data = (useResolve(control.data) as number[] | undefined) ?? [];
  const stroke = (useResolve(control.stroke) as string | undefined) ?? "currentColor";
  const max = Math.max(...data, 1);
  const points = data.map((v, i) => `${(i / Math.max(data.length - 1, 1)) * 100},${30 - (v / max) * 30}`).join(" ");
  return (
    <svg viewBox="0 0 100 30" width={100} height={30}>
      <polyline points={points} fill="none" stroke={stroke} strokeWidth={2} />
    </svg>
  );
};

/** The Fluent pack + our control: spread to extend (or override) $type entries. */
const myPack: LeafPack = {
  ...fluentPack,
  controls: { ...controlRegistry, Sparkline: SparklineView },
};

To render with a custom pack, compose the same three providers MeshAreaView composes — RegistryProvider (which pack), ScopeProvider (which AreaSource + root area), RenderArea:

import { FluentProvider, webLightTheme } from "@fluentui/react-components";

export function App({ source }: { source: AreaSource }) {
  return (
    <FluentProvider theme={webLightTheme}>
      <RegistryProvider pack={myPack}>
        <ScopeProvider source={source} area="Trend">
          <RenderArea areaKey="Trend" />
        </ScopeProvider>
      </RegistryProvider>
    </FluentProvider>
  );
}

(If the stock pack suffices, <MeshAreaView source={source} rootArea="Trend" /> does all of the above with the built-in Fluent pack.)

Where the data comes from — AreaSource

The renderer is transport-agnostic: it consumes an AreaSource — the {areas, data} tree plus an event sink:

// from @meshweaver/react (area/types.ts)
export interface AreaSource {
  getState(): AreaTree;                          // current {areas, data} snapshot
  subscribe(listener: () => void): () => void;   // change notification
  emit(event: MeshEvent): void;                  // clicks, edits, blur, closeDialog
}

Inside your component, the binding hooks (useResolve, useAreaState, useEmit, useScope) give you the same two-way data binding the built-in controls use — a bound property is a /data pointer, edits are emitted as update events (useEmit()({ kind: "update", area, pointer, value })) and echoed back through the stream.

3. One React singleton — plugins receive the host's React

@meshweaver/react declares react and react-dom as peerDependencies, not dependencies. A control plugin must do the same:

// your plugin's package.json
{
  "peerDependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0"
  }
}

Never bundle React into a plugin. Two React copies in one page break the rules of hooks and context identity: the renderer hands your component the leaf pack and the area scope through React context (RegistryProvider, ScopeProvider) — a component rendered by a different React instance sees null context and throws ("MeshWeaver control rendered without a leaf pack"). The host application owns the single React; plugins compile against it and receive it at runtime.

4. Runtime loading — native ESM import(url)

Because a pack is plain data (Record<string, ControlComponent>), controls can be added without rebuilding the host: publish the plugin as an ES module and load it with the browser's native dynamic import():

// The plugin module's contract: default-export its $type → component entries.
// (plugin: export default { Sparkline: SparklineView } satisfies Record<string, ControlComponent>)
const module = await import(/* @vite-ignore */ pluginUrl);

const pack: LeafPack = {
  ...fluentPack,
  controls: { ...controlRegistry, ...module.default },
};

Two requirements for this to work:

  1. React must stay external. Build the plugin with react/react-dom (and @meshweaver/react, if imported) marked as externals, and serve them to the browser via an import map so the plugin's import "react" resolves to the host's singleton — this is rule 3 enforced at the module-graph level.
  2. Re-render with the new pack. A pack is ordinary React state — load the module, merge its entries, set state. Controls whose $type arrived from the server before the plugin loaded render through fallback until then; the swap re-renders them for real.

Recap

Step Server (C#) Client (React)
Define record SparklineControl : UiControl<SparklineControl>(...) const SparklineView: ControlComponent = ...
Register .WithTypes(typeof(SparklineControl)) controls: { ...controlRegistry, Sparkline: SparklineView }
Render return it from any layout area RegistryProvider + ScopeProvider + RenderArea (or MeshAreaView for the stock pack)
Ship part of your hub module ESM module, React as peer/external, loadable via import(url)
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.