Data binding in MeshWeaver connects your data objects to UI controls reactively and bidirectionally. The server pushes updates to the GUI; user edits flow back to the server. The whole pipeline is live β€” when the underlying node changes anywhere in the mesh, every subscribed view re-renders without a page refresh.

Native clients bind the same way. An external participant client (the React Native app, a SignalR/gRPC participant) reads with hub.GetMeshNodeStream(path) and writes with .Update(...) β€” identical rule, just marshalled to the device UI thread over the participant socket.

flowchart LR subgraph Server D[Data Object] end subgraph Client UI[UI Controls] end D -->|"Server pushes updates"| UI UI -->|"User edits sync back"| D

The Golden Rule: the GUI is fully data-bound

🚨 Backend layout areas declare what to render β€” they never fetch instances and never put concrete values into controls. All value resolution, every read of a MeshNode's content, and every write-back of user input happens on the GUI side via a per-node IMeshNodeStreamCache subscription.

This is non-negotiable, for three reasons:

  1. No deadlocks. Backend rendering stays purely synchronous β€” no await, no Task<T>, no IAsyncEnumerable. Every async/await/QueryAsync chain put in a layout area has eventually deadlocked the hub or returned stale content. Removing the backend fetch removes the entire problem class.
  2. Live updates. The GUI subscription stays open for the lifetime of the component. Backend-loaded values freeze on first render; cache-subscribed views never go stale.
  3. CQRS-correct reads. Hub.GetMeshNodeStream(path) (backed by the process-wide IMeshNodeStreamCache) is the authoritative read path β€” it goes to the owning hub's workspace, never through the lagged read-side index. See CQRS β€” Queries, Reads, Writes, Operations.

Responsibility split

Side Responsibility
Backend layout area Build a UiControl tree. Pass paths (or JsonPointerReferences) into controls. Never call meshQuery.QueryAsync(...), never await data, never await PermissionHelper.GetEffectivePermissions(...) (compose its IObservable<Permission> with CombineLatest instead).
GUI Blazor view (.razor.cs) In BindData(), subscribe via Hub.GetMeshNodeStream(NodePath) using AddBinding(...). Write user edits back via Hub.GetMeshNodeStream(NodePath).Update(fn).Subscribe(...).

Backend: declare the binding, don't fetch the data

// ❌ ANTI-PATTERN β€” backend loads node, builds control with concrete values
var userNode = await meshQuery.QueryAsync<MeshNode>($"path:{userPath}").FirstOrDefaultAsync();
var card = MeshNodeThumbnailControl.FromNode(userNode, userPath);

// βœ… CORRECT β€” backend declares the binding path; GUI loads + displays
var card = new MeshNodeThumbnailControl { NodePath = userPath };

The backend layout-area method must not be async Task<UiControl>. Return UiControl directly. If it needs to rebuild reactively on workspace changes, return IObservable<UiControl?> and compose with Observable.Return / Select β€” never SelectMany(async ...), never await.


Templates first, data later

A layout area is a TEMPLATE. It emits its whole control tree on the first render, shows a loading shape where data has not arrived, and BINDS its data β€” it never loads the data on the hub and bakes the values into controls.

The wrong shape, and why it is slow: the area subscribes to the node (or a query) on the hub, waits for the answer, and only then builds controls out of the values. The page shows nothing but the area's spinner until the slowest read has answered β€” the owning hub activating, a cold NodeType compile, a partition fan-out β€” and what finally renders is a snapshot that an edit made elsewhere never reaches. The tells:

The reference conversion: the Markdown Edit page

Before β€” the page waited for the node, then froze its markdown into the editor:

private static UiControl BuildArea(LayoutAreaHost host, bool trackChanges)
    => Controls.Stack
        .WithView((h, ctx) => host.Workspace.GetMeshNodeStream().Take(1).Select(node =>
            BuildEditContent(host, node, hubPath, hubAddress,
                MarkdownOverviewLayoutArea.GetMarkdownContent(node),   // ← value baked in on the hub
                trackChanges)));
// … inside BuildEditContent:
new MarkdownEditorControl().WithValue(initialContent).WithAutoSave(hubPath, hubPath);

After β€” every control is declared up front and bound by PATH; nothing on the hub reads the node (MarkdownEditLayoutArea.BuildTemplate):

public static UiControl BuildTemplate(string nodePath, bool trackChanges, string? locale)
{
    // Title: the node's Name, read and written by the GUI through the node stream.
    var title = new TextFieldControl(new JsonPointerReference(nameof(MeshNode.Name)))
    {
        DataContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath, bindContent: false)
    };
    // Body: a POINTER into the node's MarkdownContent, not the text.
    var editor = new MarkdownEditorControl
        {
            Value = new JsonPointerReference("content"),
            DataContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath)
        }
        .WithAutoSave(nodePath, nodePath);
    return Controls.Stack.WithView(/* header with title */).WithView(editor);   // all STATIC views
}

The editor view resolves the pointer through MeshNodeBindingExtensions.Bind (IMeshNodeStreamCache underneath) and stays subscribed, so the page renders at once and follows the node live. test/MeshWeaver.Graph.Test/MarkdownEditIsATemplateTest pins both halves: every view in the template is a control (no deferred view the first render leaves empty), and the template's pointer reads the node's markdown and then follows a later edit.

The toolkit β€” use these, never a new one

You need to show Declare Resolved
A field of a node (title, description, a content property) Any form/display control with a JsonPointerReference and DataContext = LayoutAreaReference.GetMeshNodeDataContext(path[, bindContent: false]) GUI, MeshNodeBindingExtensions.Bind
A node's markdown body MarkdownEditorControl { Value = pointer, DataContext = nodeCtx } (edit) Β· CollaborativeMarkdownControl { NodePath } (read) β€” the node's default Overview additionally carries Html, the same Markdig output POST /api/mesh/render-markdown answers, rendered off the hub; Html is authoritative when non-null (remote clients show it directly), null means not pre-rendered and the client falls back to the endpoint; the Blazor view renders in-process from the node stream and ignores it GUI
A node as a card MeshNodeThumbnailControl.ForPath(path) / MeshNodeCardControl with the PATH β€” never FromNode(loadedNode) GUI, per-node cache
A list of nodes Controls.MeshSearch.WithHiddenQuery(…) Β· MeshNodeCollectionControl.WithQueries(…) β€” the GUI runs the query GUI
A grid of rows only the hub can compute (a query projected to rows) rowsFeed.BindGrid(id, emptyText, failedText) (MeshWeaver.Layout.DataGrid.DataGridBinding) β€” the DataGridControl is returned AT ONCE, bound to /data/{id}; Loading is bound until the first row set, EmptyContent carries the empty or failure text hub β†’ /data, bound by pointer
Text only the hub can compute (a status line, a rendered digest) markdownFeed.BindMarkdown(id, loading, failed) (MeshWeaver.Layout.FeedBinding) hub β†’ /data, bound by pointer
Rows computed on the hub (a projection, an aggregate) stream.BindMany(id, row => template) / stream.Bind(x => template, id) (Template in MeshWeaver.Layout) β€” the control is returned AT ONCE and the stream feeds /data/{id} hub β†’ /data, bound by pointer
One text computed on the hub (a serialization, a rendered fragment) textStream.BoundMarkdown(id) / htmlStream.BoundHtml(id) (BoundProjections in MeshWeaver.Layout) β€” the Template.Bind row above for the common single-text case hub β†’ /data, bound by pointer
Something that decides the page's STRUCTURE (which catalog, which type) Read it from the hub's CONFIGURATION, never the node: NodeTypePathHolder (the type the hub was bound to), MeshDataSource.ContentType, the hub's own markers (NodeTypeCatalogMode) hub configuration β€” no wait
A whole sub-page that genuinely must compute A nested LayoutAreaControl with .WithSpinnerType(SpinnerType.Skeleton) β€” the parent page renders, the slot shows the skeleton hub, deferred to the slot only

The default node page's secondary areas (converted)

These areas of every node hub are templates; each pins its shape in test/MeshWeaver.Graph.Test/NodePageAreasAreTemplatesTest:

Area Template
Thumbnail (default and Markdown) MeshNodeThumbnailControl.ForPath(hubPath) β€” the card's view binds title and image from the node.
NodeTypes Own type from NodeTypePathHolder as a card by path; the types at this level as a MeshSearch the GUI runs. It used to take a one-shot query snapshot that never showed a type added later.
Search The ordinary catalog is emitted at once; whether to show a NodeType's instance catalog is read from configuration. A NodeType DEFINITION's catalog is emitted at once too: the breadcrumb trail plus the NodeTypeInstances slot with a skeleton, carrying the page's query string (?groupBy, ?q, …). Only that slot reads the node, because the instance list's query and create link are built from the definition's DefaultNamespace and RestrictedToNamespaces (NodeTypeCatalogQuery.From, pure). It is a STRUCTURE read like NodeContentForm below: the list itself is still a MeshSearch the GUI runs, and the slot re-renders only when the query or the create link changes, so an edit of the definition reaches the open page. Binding MeshSearch.HiddenQuery by pointer would remove the slot; the views resolve it as a literal string today. The ratchet's text scan does not count the slot, because its controls are built by pure helpers (BuildNodeTypeSearch, BuildCatalog); unlike NodeContentForm, it has left the inventory, and it is a structure read all the same.
Notebook (Markdown) Header with the name bound by pointer; the cells β€” parsed from the markdown β€” render in the NotebookCells slot with a skeleton.
$Schema (self) The content type the hub's MeshDataSource was configured with; no read.
$Data, $Content (self) A markdown/HTML control bound to a projection of the node, following later edits. On a node hub the layout's DataPathViews renderer also matches $Data and, running after the named renderer, overwrites it β€” a client sees that one there.

The default node page itself (converted)

The main page and its siblings are templates as well. test/MeshWeaver.Graph.Test/NodePageIsATemplateTest pins their shape:

Area Template
Overview, Data (BuildDetailsTemplate) The page is emitted once the viewer's permissions are known. Permissions are STRUCTURE: they decide the page or the denial, and whether fields are click-to-edit. The header binds its title, icon and provenance line to a projection the hub feeds into /data/nodeHeader (NodePageProjections.Header). A node excluded from the header context hides the header through its bound style. The property form is the configured content type's form (ConfiguredContentType: the MeshDataSource's, else the type the mesh registered for the NodeType), with every field bound to the node. The markdown body is a MarkdownControl bound to /data/nodeBody, and it is hidden while the node has no body. A NodeType definition's description is bound the same way.
The provenance strip WithNodePage composes (ComposeProvenance) Emitted with the page. Its line is bound to /data/nodeProvenance (NodePageProjections.Meta). The viewer's zone and language are captured on the render turn.
Edit (BuildEditTemplate) The header template, plus the configured content type's form in pure edit mode, bound to the node.
NodeContentForm (OverviewLayoutArea.ContentForm) The fallback slot, rendered with a skeleton, for a hub whose configuration names no content type. The form's SHAPE can then only come from the node's own $type. It is the one place the default page still reads the node on the hub, and that read decides structure only, so it stays in the ratchet's inventory.

Every projection is a pure Select over the hub's own node, published with Template's stream Bind. That Bind subscribes in the control's buildup and is disposed with its area, so the page is never rebuilt on an edit. The one-way /data mirror of the node's Content, which a few read-only labels of the property form read, is opened the same way (NodePageProjections.MirrorContent). The ratchet's text scan does not see a load that sits in another file. Keeping the projections in NodePageProjections.cs therefore relies on that blind spot, so the file's contract is narrow: projections and the mirror, never a control.

Consumers that read these controls' values server-side must resolve pointers. The document export (AreaMarkupRenderer, MeshWeaver.Plugins) resolves a node-bound pointer through MeshNodeBindingExtensions.Bind and a /data pointer through LayoutClientExtensions.DataBind, with the control's own data context or the one its container cascades. That change landed before this conversion went live.

The loading shape

Two more shapes, from the framework's own areas

Not convertible at the helper: LayoutHelperExtensions.StreamView<T> hands the caller's viewFactory the loaded items, so its contract IS the bake; it retires when its callers (MeshNodeLayoutAreas.Thumbnail/Metadata here, three MeshWeaver.Graph.Views areas in MeshWeaver.Plugins) are templates. A list with a per-row action (revoke, archive, rotate) is a bound list too: a button inside a bound row (ItemTemplateControl, TemplateColumnControl) posts a ClickedEvent that carries its row β€” see Row-scoped actions below; DataGridControl.WithClickAction + DataGridCellClick (the row arrives as the payload β€” GitHistoryTab) remains the shape for a click on the cell itself. The coupon admin list opens the clicked coupon from a row-scoped button in a TemplateColumnControl; the registration-key and instance-grant lists are fed grids that bind columns only (no row acts).

The authoring samples β€” what a NodeType author copies

The in-mesh samples are converted, so copy them rather than the framework areas still on the inventory:

Sample Shape it shows
samples/Graph/Data/Northwind/{Customer,Supplier} Stored fields only β€” every value a JsonPointerReference into the content, DataContext = GetMeshNodeDataContext(path); the area is (host, _) => OverviewTemplate(host.Hub.Address.ToString())
samples/Graph/Data/Northwind/Employee Β· …/Product Stored fields bound by pointer and a value only the hub can compute (dates in the viewer's format, a stock status) β€” a FEED function that builds no control, bound with feed.Bind(x => Controls.Markdown(x), id) (Template.Bind)
samples/Graph/Data/{Northwind,ACME}/Article Title and body as pointers into the node; the composed metadata line as a feed; the Thumbnail area as new MeshNodeThumbnailControl(path, path) β€” the thumbnail view reads name, abstract and image itself
samples/Graph/Data/Northwind/ReportsCatalog Children as Controls.MeshSearch.WithHiddenQuery(…) β€” the GUI runs the query; the hub reads neither the catalog nor its reports
samples/Graph/Data/PythonDemo/PrimeReport A whole view that must compute (a Python run) β€” one markdown control bound to /data, fed by an IIoPool-backed feed

Each sample's Test/ folder asserts its template on the mesh: the template is built from a PATH (and, for a fed control, an Observable.Never feed β€” the template must be whole while its feed is silent), and LayoutTemplate.DeferredViews(template) must be empty β€” LayoutTemplate (MeshWeaver.Layout) is the platform's reading of a control tree, public so in-mesh C# can use it. The cases also pin which pointers are bound against which context. A feed's own subscription is opened by Template.Bind's build-up, so it belongs to the rendered area and ends with it; a feed reports a failure as text and a log line, never by going quiet.

A decision over several fields: publish a projection, bind the template to it

Some of what a page shows is not a FIELD of a node but a DECISION over several β€” a compile panel whose chip, colour and button label depend on status Γ— build presence Γ— dirty flag, in the viewer's language. A field binding cannot express that, and computing it in a GetMeshNodeStream().Select(…) that builds controls is the bake. The shape the NodeType pages use:

  1. A pure record of the decided values (NodeTypeStatusView β€” title, status lines, panel style, button label, links), made by a pure From(node, definition, path, locale) that tests pin without a renderer.
  2. A projection β€” the ONE read of the node β€” GetMeshNodeStream().Select(NodeTypeStatusView.From…), a function that builds no control.
  3. A template whose controls carry DataContext = LayoutAreaReference.GetDataPointer(id) and pointers into the record (Controls.Body(pointer), Style = pointer, a button's Disabled = pointer), with the projection attached by control.PublishingTo(id, projection) (MeshWeaver.Graph.LayoutProjection): a buildup that publishes to /data/{id} for as long as the AREA lives and is disposed with it.

The template is emitted at once and the values fill in; nothing is ever interpolated into a control. Lists go further and leave the hub entirely: the NodeType release history, the Settings Groups tab and the Admin Data Sources tab are Controls.MeshSearch.WithHiddenQuery(…), run by the viewer's client.

Two things a template cannot close from the server side β€” both live in the Blazor layer:

The ratchet

test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard counts, per file under src/, memex/ and samples/, the layout-area units (methods, local functions, lambdas taking a LayoutAreaHost) that both READ data and BUILD controls. The seeded inventory is test/LayoutAreaDataBakeSites.allow; it may only shrink. Converting an area means lowering its line (and TotalBudget) in the same change. It is a text heuristic, and it says so: a load reached through another file's helper is missed, and an area that reads data only to choose its STRUCTURE (a permission gate) is counted β€” so the file is an inventory to work down, not a verdict on every line.


GUI: subscribe via the cache, re-render on emission

The canonical Blazor view template. Reads and writes both go through Hub.GetMeshNodeStream(path), which returns a MeshNodeStreamHandle backed by the process-wide IMeshNodeStreamCache. Multiple views on the same path share one upstream subscription; writes through the handle's .Update(...) are visible to every reader.

Access-checked. The stream is gated by the current user's effective Read permission on the node. The cache asks the owning hub via GetPermissionRequest, caches the answer per (path, userId) for 30 s, and terminates the observable with UnauthorizedAccessException if Read is not granted. Subscribers should handle that error (toast, navigate to AccessDenied, render empty state) rather than letting it propagate. See AccessContextPropagation.md.

public partial class MyView : BlazorView<MyControl, MyView>
{
    public string? Title { get; private set; }
    public string? ImageUrl { get; private set; }

    protected override void BindData()
    {
        base.BindData();

        // 1. Declare bindings from the control's own properties (DataContext / refs)
        DataBind(ViewModel.NodePath, x => x.NodePath);

        if (string.IsNullOrEmpty(NodePath)) return;

        // 2. Subscribe β€” every emission re-renders this component
        AddBinding(Hub.GetMeshNodeStream(NodePath)
            .Where(node => node is not null)
            .DistinctUntilChanged()
            .Subscribe(node =>
            {
                Title = node.Name;
                ImageUrl = MeshNodeThumbnailControl.GetImageUrlForNode(node);
                InvokeAsync(StateHasChanged);
            }));
    }
}

Key points to remember:


Writing user edits back

The same handle is the write path. Its Update takes a MeshNode β†’ MeshNode lambda and returns a cold IObservable<MeshNode> β€” the write only happens on Subscribe:

private void OnTitleChanged(string newTitle)
{
    if (string.IsNullOrEmpty(NodePath)) return;
    Hub.GetMeshNodeStream(NodePath).Update(current => current with { Name = newTitle })
        .Subscribe(_ => { }, ex => Logger.LogWarning(ex,
            "Title update failed for {Path}", NodePath));
}

Because the write routes through the same shared upstream handle every reader is subscribed to:

  1. The owning hub applies the patch and persists.
  2. This view's Hub.GetMeshNodeStream(NodePath) subscription receives the echo and re-renders.
  3. Every other GUI watching the same path sees the patch through their own subscription.

No separate DataChangeRequest is needed for own-node edits inside a bound view.

Server-side mirror. The same rule holds server-side: every mesh-node mutation goes through workspace.GetMeshNodeStream(path).Update(...) β€” which internally routes through the same IMeshNodeStreamCache. State machines (compile, thread execution, satellite operations) flip a RequestedX field on the node's content; the owning hub's watcher reacts. Full reference: Requesting Work via stream.Update().


🚨 ABSOLUTE: edit node content by binding to the node stream β€” NEVER replicate into /data + a save subscription

Editing a mesh node's content means binding the GUI client to the node's own stream and writing edits straight back to it. There is exactly ONE source of truth β€” Hub.GetMeshNodeStream(path) (the process-wide IMeshNodeStreamCache). Reads come from it; edits write back through GetMeshNodeStream(path).Update(...).

The forbidden antipattern (it has appeared in many editors and must not be added to new ones):

// ❌ FORBIDDEN β€” replicate-then-save. Two sources of truth glued by a debounced loop.
host.UpdateData(dataId, node.Content);                          // 1. copy the node into a /data replica
// ... controls bound to /data/{dataId} ...                      // 2. edit the replica
host.Stream.GetDataStream<object>(dataId)                        // 3. a SERVER-SIDE save subscription
    .Debounce(...).Subscribe(c => GetMeshNodeStream(path).Update(n => n with { Content = c }));

Why it's wrong: the /data/{id} copy and the node stream are two stores that drift (an out-of-band write to the node β€” e.g. a status field β€” never reaches the replica), and the debounced Subscribe(...Update...) is a hidden save loop that fires spurious writes, races the echo, and clobbers fields it didn't edit. OverviewLayoutArea.SetupAutoSave is this antipattern; do not call it and do not write your own variant (SetupNodeMetadataAutoSave, SetupNodeTypeConfigAutoSave, a hand-rolled GetDataStream(id).Throttle().Subscribe(...Update...), or a "Save" button that reads /data and writes the node).

The correct pattern β€” a node-bound editor. The backend layout area only DECLARES the editor with a node path; a Blazor view binds it to the node stream:

// βœ… Backend layout area β€” declare the binding, compute the fields from the content type:
stack.WithView(MeshNodeContentEditorControl.ForType(nodePath, typeof(MyContent)));

// βœ… The Blazor view (the ONLY place reads/writes live) β€” bind to the node stream:
AddBinding(Hub.GetMeshNodeStream(NodePath)
    .Where(n => n is not null)
    .Subscribe(node => { LoadValues(node); InvokeAsync(StateHasChanged); }));   // reads

// edit -> per-field read-modify-write straight to the node (set ONLY the edited field):
Hub.GetMeshNodeStream(NodePath)
    .Update(node => node with { Content = PatchOneField(node.Content, key, value) })
    .Subscribe(_ => { }, ex => Logger.LogWarning(ex, "persist failed for {Path}", NodePath));

No /data replica, no SetupAutoSave, no Save button, no debounce-and-save subscription. MeshNodeContentEditorControl (control in MeshWeaver.Graph, view MeshNodeContentEditorView in MeshWeaver.Blazor) is the reusable generic editor for simple scalar/bool content. For rich content (markdown, mesh-node picking) use the dedicated already-node-bound controls β€” MarkdownEditorControl.WithAutoSave(hubAddress, nodePath) (writes via the cache), MeshNodePickerControl, CollaborativeMarkdownView. Reference editor: MeshNodeEditorView (MeshWeaver.Blazor.Graph) via the MeshNodeEditor/IMeshNodeEditor client wrapper.

The same rule covers create-on-absent: a node the editor writes to must EXIST first β€” create it with meshService.CreateNode(...) (read existence via GetQuery, empty-on-absent), NEVER GetMeshNodeStream(path).Update on an absent path (it NotFound-storms). Update mutates; only a create brings a node into being.

🚨 READING is a different obligation, and it is the framework's, not yours. "Create it first" cannot cover the two states that actually happen: a bound node deleted while the page is still open, and one deliberately not written until the user acts (a learner's answers node, written by the first answer β€” creating it on render would write a node for everyone who merely looked). So MeshNodeBindingExtensions.Bind β€” the seam every node-bound control reads through β€” gates its point read on a live exact-path existence query and simply draws the control empty while the node is absent, staying subscribed so the node appearing populates it. The cache can end an already-open read before the independent existence feed reports the delete; the binding turns only that exact node's typed delete outcome (or its routing NotFound) into the same empty field and follows the existence feed to a recreated node. Other read faults still reach the view's error surface. Do not add a broad catch at a control or layout area: it can conceal an unavailable store or denied read, and a new point read of an absent path opens a storm-breaker window that also fast-fails WRITES. See CQRS and Content Access β†’ An OPTIONAL node (Systemorph/MeshWeaver#3517, #6129).

Node-bound DataContext β€” reuse the rich form-gen, bound to the node

For a RICH editor (text + number + checkbox + markdown + [MeshNode] picker + [Dimension] select), you don't hand-roll controls β€” you let the framework's form generator (EditLayoutArea.BuildPropertyForm / MapToToggleableControl / the Edit macro) build them, then point their DataContext at the node instead of a /data/{id} replica. The generated controls then read each field straight from the node stream and write each edit straight back β€” ONE source of truth, no replica, no save subscription.

Encode the node-bound DataContext with LayoutAreaReference.GetMeshNodeDataContext(...):

// Field pointers resolve against the node's Content JSON (content-typed editors):
var ctx = LayoutAreaReference.GetMeshNodeDataContext(node.Path);                    // bindContent: true (default)

// Field pointers resolve against the WHOLE node JSON β€” for top-level fields
// (Name / Description / Icon / Category / Order β€” the "metadata" editors):
var ctx = LayoutAreaReference.GetMeshNodeDataContext(node.Path, bindContent: false);

// …optionally nested one level deeper (e.g. a Thread's inline composer object):
var ctx = LayoutAreaReference.GetMeshNodeDataContext(node.Path, bindContent: false, subPath: "content/composer");

// Pass it to the standard form generator (or set it as a control's DataContext directly):
stack.WithView(EditLayoutArea.BuildContentView(host, new ContentViewOptions {
    DataId = dataId, ContentType = contentType, CanEdit = canEdit, BoundDataContext = ctx }));

Mechanics (so you know what's load-bearing):

Binding a rich control to a node field

The Monaco controls carry their text in a bindable slot too, so an area that shows or edits a node's text renders the control AT ONCE and never loads the node. Each has a BindToNode builder that sets a relative pointer and the node-bound DataContext above in one call:

Control Bindable slot(s) Builder What the renderer does
CodeEditorControl Value .BindToNode(nodePath, "instructions") reads the field live off the node stream, writes each edit straight back to THAT field (per-field read-modify-write)
DiffEditorControl Original, Modified .BindToNode(nodePath, "baselineText", "text") binds both panes; redraws when either field changes. Read-only
// βœ… Agent Edit β€” the instructions editor IS the node field. No /data copy, no Save button.
stack.WithView(new CodeEditorControl().WithLanguage("markdown").WithHeight("400px")
    .BindToNode(agentPath, "instructions"));

// βœ… Post Changes β€” the diff compares two fields of the post, live.
stack.WithView(new DiffEditorControl { Language = "plaintext", Height = "560px" }
    .BindToNode(postPath, "baselineText", "text"));

Anti-patterns β€” never do these

❌ Wrong Why βœ… Right
await meshQuery.QueryAsync<MeshNode>($"path:{x}").FirstOrDefaultAsync() in a layout area Lagged index, deadlock-prone, freezes view Pass path; GUI subscribes via Hub.GetMeshNodeStream(path)
SelectMany(async nodes => await ...) for data resolution async lambda inside an observable chain β€” same deadlock surface Pass paths; bind in GUI via the cache
MeshNodeThumbnailControl.FromNode(loadedNode, ...) after a backend fetch Concrete values frozen at render time new MeshNodeThumbnailControl { NodePath = path }
.Take(1) on a display stream View stops updating after first emission Stay subscribed for the lifetime of the component
await PermissionHelper.GetEffectivePermissions(...).FirstAsync() in a layout area Hub deadlock candidate Compose the IObservable<Permission> via CombineLatest; bind permissions on the GUI side
try { ... } catch { /* swallowed */ } around backend reads Errors disappear, debugging impossible Propagate via OnError; framework handles it
workspace.GetRemoteStream<MeshNode, MeshNodeReference>(addr, ...) directly in a Blazor view Opens a per-view upstream handle; bypasses IMeshNodeStreamCache; multiplies subscriptions; writes through the cache aren't observed Hub.GetMeshNodeStream(path) β€” shared, write-coherent
host.UpdateData(id, node.Content) + GetDataStream(id).Debounce().Subscribe(...GetMeshNodeStream(path).Update...) to edit node content (a.k.a. SetupAutoSave) Replicate-then-save: two stores drift, the save loop races the echo and clobbers unedited fields MeshNodeContentEditorControl.ForType(path, typeof(T)) β€” the GUI view binds to GetMeshNodeStream(path) and writes per-field via .Update(...); no replica, no save subscription
A "Save" button that reads /data/{id} and writes the node The edit should already be on the node via the bound stream Node-bound editor; edits persist on change through GetMeshNodeStream(path).Update(...)

The "load, then bake" shape is ratcheted in every repository

A layout area that reads data on its hub (a node stream, a query, a workspace stream) and builds controls out of the values is counted, and the count may only go down:

The satellite allow-file is shrink-only. These verdicts fail the Validate node repos check:

Verdict Meaning
NEW A file bakes data and has no line in the allow-file.
MORE A file holds more baking units than its line allows.
MISSING The repository has no allow-file. This is red, never skipped.
ADDED On a pull request, the tree holds more baking units than the base did. Adding an area together with its line is therefore caught. Moving an existing unit to another file keeps the total, so it passes.
GREW / RAISED On a pull request, the allow-file's total, or one of its lines, is higher than in the base's copy.
STALE On a pull request, a file this PR converted now holds fewer units than its line. Lower the line or delete it in the same PR.

A stale line that the pull request did not cause is a warning, not a failure. This happens when the line was already above its file at the base, for example after two converting PRs merged at the same time. Failing it would turn every unrelated PR red. ADDED already stops anyone from re-using the spare allowance, and any PR may carry the one-line tidy.

To print the current inventory in allow-file form, run python3 <core>/.github/scripts/check-layout-area-data-bake.py --root . --report.


Where to look for working examples


Layout Area Structure

A layout area has two conceptual sections: areas (the rendered UI controls) and data (the bound objects). Controls reference data locations using JsonPointerReference.

flowchart TB subgraph LayoutArea["Layout Area"] subgraph Areas["areas/"] TF1["TextFieldControl<br/>Value: β†’ /data/person/name"] TF2["NumberFieldControl<br/>Value: β†’ /data/person/age"] end subgraph Data["data/"] P["person/<br/>{ name: 'Alice', age: 30 }"] end end TF1 -.->|JsonPointerReference| P TF2 -.->|JsonPointerReference| P

When the user types in the TextFieldControl, the value at /data/person/name updates. When server code calls UpdateData(...), every bound control reflects the new value automatically.


DataContext

DataContext sets the base path for data binding. All JsonPointerReference values are resolved relative to it.

// EditorControl with DataContext pointing to /data/person
new EditorControl { DataContext = "/data/person" }

When you call Edit(instance, "person"), the data is stored at /data/person and the generated controls automatically receive DataContext = "/data/person".


JsonPointerReference

JsonPointerReference points a control's value to a location in the data section. The pointer is relative to DataContext:

// TextFieldControl bound to the "name" property
new TextFieldControl(new JsonPointerReference("name"))

// NumberFieldControl bound to the "age" property
new NumberFieldControl(new JsonPointerReference("age"))

With DataContext = "/data/person":

flowchart LR subgraph Control DC["DataContext: /data/person"] Ref["Value: JsonPointerReference('name')"] end subgraph Resolved Path["/data/person/name"] end DC --> Path Ref --> Path

🚨 An ABSENT DataContext does not disable the binding β€” it RE-ROOTS it

Worth knowing before you read the stack trace in #3711, because the exception there names a JSON parse and the fault is a lost context.

LayoutClientExtensions.GetPointer resolves a relative pointer against the data context β€” and when there is no context it does not refuse. It promotes the pointer to an absolute one:

if (pointer.StartsWith('/'))
    return pointer.TrimEnd('/');
if (string.IsNullOrWhiteSpace(dataContext))
    return string.IsNullOrEmpty(pointer) ? "/" : $"/{pointer}";   // ← relative becomes ABSOLUTE
return $"{dataContext}/{pointer.TrimEnd('/')}";

LayoutExtensions.GetStream then reads segment 0 as a COLLECTION and segment 1 as a JSON-encoded id. So what happens next depends on how many segments the pointer has, and the two cases are opposites:

pointer, context absent resolves to outcome
answers/q1 (2 segments) /answers/q1 Deserialize<string>("q1") throws β€” 'q' is an invalid start of a value
answers (1 segment) /answers SegmentCount == 1 β‡’ no id decode β‡’ binds silently against the layout stream's own root

🚨 The crash is the lucky case. The one-segment form reports nothing and reads β€” and through BlazorView.UpdatePointer, writes β€” against a ROOT path of the layout stream's own document (/answers, treated as a root collection) instead of the node the view was meant to be bound to.

Be precise about which wrong place that is: it is not the area's /data/{id} replica. LayoutAreaReference.GetDataPointer builds /data/"{id}"/…, so a value in the data section is two segments deeper and JSON-encoded. A context-less relative pointer lands beside /areas and /data, at a root key the layout stream does not define β€” which is why the read yields nothing and the write creates a sibling of the document's real sections. Same class as the replicate-then-save outcome this page forbids above (a write that leaves the node it was bound to untouched), reached by accident rather than by design β€” but a different address, and diagnosing it against the /data storage model sends the reader to the wrong place.

Two consequences:

  1. Never "fix" such a crash by making the id decode tolerant. It converts the loud case into the silent one β€” for the quiz in #3711 that means a learner's pick written into the layout replica instead of their answer sheet.
  2. A bind that reads nothing, or reads the wrong thing, with no error, is a DataContext question first. Check that the control's context reached the CLIENT β€” it is a [CascadingParameter] supplied by DispatchView, not a property the view reads off the control it renders β€” before you look at the pointer.

Updating Data from the Server

To push new data to bound controls from server code, use UpdateData:

// Push new data to the stream β€” all bound controls update automatically
host.UpdateData("person", new Person { Name = "Bob", Age = 25 });

This updates /data/person, and every control bound to that path reflects the change immediately.

When a fed stream faults

A stream feeding a binding β€” stream.Bind(template, id), stream.BindMany(id, template), host.SubscribeToDataStream(id, stream) β€” can fault: a query stall, a projection that throws on an empty cube, a denied read. The framework subscribes every such feed WITH an error arm (LayoutAreaHost.FeedData), so a fault never escapes to Rx's default OnError, which rethrows on the producer's thread and, off the thread pool, kills the process (the #5650 crash shape). Instead:

So do not wrap a fed stream in .Catch(...) to "protect" the view, and do not subscribe a feed by hand with stream.Subscribe(x => host.UpdateData(id, x)): that bare Subscribe is exactly the missing error arm. Inside MeshWeaver.Layout, use host.FeedData(area, id, stream); everywhere else, Bind/BindMany/SubscribeToDataStream. Pinned by BindFeedFaultTest.


The Edit Macro

Edit is the fastest way to create a data-bound editor. It inspects the object's properties and generates the appropriate controls automatically β€” no manual JsonPointerReference wiring required.

// Creates a fully bound editor for a Calculator record
host.Hub.Edit(new Calculator(), "calc");

Property-to-control mapping

Property Type Generated Control
double, int, numeric types NumberFieldControl
string TextFieldControl
DateTime DateTimeControl
bool CheckBoxControl
[Dimension<T>] SelectControl (options from workspace)
[UiControl<T>] Custom control specified by the attribute

Example

public record Calculator
{
    [Description("The X value")]
    public double X { get; init; }

    [Description("The Y value")]
    public double Y { get; init; }
}

// Produces an EditorControl with two NumberFieldControls
// bound to /data/calc/x and /data/calc/y
host.Hub.Edit(new Calculator(), "calc");

Live demo

The cell below shows the property-type mapping in action β€” a Calculator record rendered as a table of controls, with a computed result:

var rows = new[]
{
    ("X", "double", "NumberFieldControl", "/data/calc/x"),
    ("Y", "double", "NumberFieldControl", "/data/calc/y"),
};

var header = "<tr><th>Property</th><th>Type</th><th>Generated Control</th><th>Bound path (DataContext = /data/calc)</th></tr>";
var body = string.Join("", System.Linq.Enumerable.Select(rows, r =>
    $"<tr><td><code>{r.Item1}</code></td><td><code>{r.Item2}</code></td><td><code>{r.Item3}</code></td><td><code>{r.Item4}</code></td></tr>"));

MeshWeaver.Layout.Controls.Html($"<table>{header}{body}</table>")

Edit with a Result Callback

Add a result callback to compute derived values whenever user input changes:

// Editor that displays X + Y as the user types
host.Hub.Edit(new Calculator(), c => Controls.Markdown($"Result: {c.X + c.Y}"));

This creates:

  1. Editor controls for X and Y (bound to /data/{id}/x and /data/{id}/y)
  2. A result area that recalculates whenever either value changes
sequenceDiagram participant User participant Client participant Server User->>Client: Type "5" in X field Client->>Server: Update /data/{id}/x = 5 Server->>Server: Invoke callback with Calculator{X=5, Y=0} Server->>Client: Return Markdown("Result: 5") Client->>User: Display "Result: 5"

Row-scoped actions

A bound row template is declared ONCE and rendered by the client once per row: BindMany (an ItemTemplateControl) and a data grid's TemplateColumnControl. On the owner the template's controls exist once, at the template's area, so a button in it cannot say which row it is by its area. The click carries the row instead: the client stamps the row it rendered on the ClickedEvent (ClickedEvent.Row, a RowContext), and the action reads it from UiActionContext.Row.

// A list β€” one Archive button per row, one action for all of them.
mail.BindMany("mail", m => Controls.Stack
    .WithView(Controls.Label(m.Subject))
    .WithView(Controls.Button("πŸ—ƒοΈ").WithClickAction(ctx => Archive(ctx))));

static Task Archive(UiActionContext ctx)
{
    var row = ctx.RowAs<MailRow>();   // the row as the client rendered it (MeshWeaver.Mesh)
    var path = ctx.RowPath();         // its node path, for a node row (a `path` property)
    // … write as the clicking user; the write's own access check is the guard …
    return Task.CompletedTask;
}

// A grid (any bound DataGridControl) β€” the template column's button acts on its row.
grid.WithColumn(new PropertyColumnControl<string> { Property = "code" })
    .WithColumn(new TemplateColumnControl(
        Controls.Button("➑️").WithClickAction(ctx => Open(ctx))));

What RowContext holds:

Field Meaning
Value The row's value as the client rendered it β€” JSON. Read it with ctx.RowAs<T>(), never a cast.
NodePath() / ctx.RowPath() Path when the client set it, else the value's own path property. Null for a row that is not a node.
Pointer The row's data context (/data/"mail"/3) for a BindMany row; null for a grid row, which the client sorts and pages.
Index The row's position when it was rendered. Diagnostic only.

The rules:

A worked example with sections, group actions and status-dependent menus β€” the Todo sample (samples/Todo, TodoLayoutAreas). All seven areas used to wait for the todos and bake one menu per todo, its todo captured in the closure. Each is now ONE page (title, "Add New Todo", one bound list) fed by a pure projection (TodoProjections) into rows of ONE record, TodoEntry: a row is either a heading (a section title, a summary line, an empty state β€” optionally with a group action such as "Start All") or a todo (card + action menu, or the assignment menu). The parts a row lacks are hidden by a bound style, and so are the secondary actions its status does not offer β€” the template's shape never depends on data. Every button is declared once and reads ctx.RowAs<TodoEntry>() (TodoRowActions): an item action acts on the row's Item, and a group action on the row's Group exactly as rendered β€” what the closure used to capture now travels with the click. Pinned by test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest (every area is a template; the page renders before any todo arrives; row k's Start / Delete / Assign act on todo k for every k, also after the list changed; a heading's group action acts on its group; a no-row negative control).

Where it is wired: core MeshWeaver.Layout (RowContext, ClickedEvent.Row, UiActionContext.Row, DataGridControl.RenderSelf) and MeshWeaver.Mesh.Contract (RowAs<T>); the Blazor views in MeshWeaver.Plugins cascade the row (ItemTemplate, DataGridView) and BlazorView stamps it on every click and blur. Pinned by test/MeshWeaver.Layout.Test/RowScopedClickActionTest (N rows, row k acts on row k; a no-row negative control; the list changing between render and click; a grid template column) and, for the client half, Plugins' RowScopedActionsFromViewsTest.


Two-Way Sync Details

Changes travel as JSON Patch (RFC 6902) for efficient delta updates:

[{"op": "replace", "path": "/data/calc/x", "value": 5}]

Control-Specific Bindings

Dimension Attribute

Properties marked [Dimension] generate a SelectControl whose options are loaded from the workspace:

public record MyForm
{
    [Dimension<Country>]
    public string CountryCode { get; init; }
}

Custom Control Attribute

Use [UiControl<T>] to override which control type is generated for a property:

public record MyForm
{
    [UiControl<RadioGroupControl>(Options = new[] { "chart", "table" })]
    public string DisplayMode { get; init; }

    [UiControl<TextAreaControl>]
    public string Notes { get; init; }
}

Node cards: a bindable title and description

MeshNodeThumbnailControl and MeshNodeCardControl take their caption from data without the area loading anything. NodePath still names the node the card shows (its avatar, its click target); TitleBinding / DescriptionBinding caption it from a pointer:

// βœ… An access-assignment row: the SUBJECT's card, captioned from the ASSIGNMENT β€” live.
new MeshNodeThumbnailControl(subjectPath, subjectId)
    .BindToNode(assignmentPath, titleField: "displayName", descriptionField: "note");

// βœ… A card whose subtitle follows its own node's top-level Description.
new MeshNodeCardControl(path).BindToNode(path, titleField: null, descriptionField: "Description", bindContent: false);

// βœ… Or any pointer, e.g. into a fed /data entry.
new MeshNodeCardControl(path).BindTitle(new JsonPointerReference(LayoutAreaReference.GetDataPointer("caption")));

The controls carry the binding; the card views draw it. The precedence is the renderers' contract β€” a bound value that resolves non-empty wins over the literal Title/Description and over the node's own name, and while it has no value the card falls back to them, so the literal is the loading shape. That half ships with the card views (the Blazor MeshNodeThumbnailView / MeshNodeCardView and the React card, MeshWeaver.Plugins#2677) and is pinned there; a portal whose views predate it ignores the slots and shows the literal title and the node's name. The pointer is read under the VIEWER's identity through the same node-bound seam every form control uses (MeshNodeBindingExtensions.Bind β†’ GetMeshNodeStream, whose per-viewer gate refuses a viewer without Read on that node), so binding a caption to a node never shows its fields to a viewer who cannot read it.

FromNode(node, …) remains the shape for a node the caller ALREADY holds (a row of a query result) β€” never load a node in order to call it. NodeBoundCardControlsTest (MeshWeaver.Graph.Test) pins the control half: the pointers resolve through the renderer seam and follow a change, FromNode carries no binding, a change to a different node does not reach the card, and pointers and literals survive the wire (a literal string arrives as a string).

Charts: series and labels are already bindable

ChartControl.Series and ChartControl.Labels are object? slots that both renderers resolve through the generic binding (Blazor RadzenChartView via DataBind, React chart.tsx via useResolve) β€” so a chart area is a template today. Declare the chart with pointers, and feed the data entry from a stream:

// βœ… The chart renders at once; the series follow the feed.
stream.Select(rows => BuildSeries(rows)).Subscribe(series => host.UpdateData("economics", series));
return new ChartControl
{
    Series = new JsonPointerReference(LayoutAreaReference.GetDataPointer("economics")),
    Labels = new JsonPointerReference(LayoutAreaReference.GetDataPointer("economicsLabels")),
}.WithTitle(host.Localize("<your title key>"));

The bound value must be the WHOLE series list (ImmutableList<ChartSeries>), and its series types must be registered on the hub that receives them β€” an unregistered BarSeries drops to its base and draws empty. Pinned by BoundChartSeriesTest (MeshWeaver.Layout.Test): the series render from the feed, follow a change to it, and a pointer to a different entry does not move.

Best Practices

  1. Use records. Immutable records with init properties work best for data binding.
  2. Add metadata. [Description] and [Display] attributes improve generated UIs.
  3. Prefer Edit for forms. Let Edit generate controls automatically β€” write JsonPointerReference by hand only for non-standard layouts.
  4. Use callbacks for computed values. The result-callback pattern is the right way to derive values from user input.
  5. Never fetch in the backend. Pass paths; subscribe in the GUI. See The Golden Rule above.
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.