Page actions

Component Registry

A registry maps the type string on a spec element to an Angular component class. It is the bridge between a declarative JSON spec and the component tree your application owns. The running example registers four small components, streams three specs through them, and shows exactly what each component receives when it mounts.

What the demo does

The Run tab shows a split surface: the rendered output on the left, the JSON that produced it on the right, and a transport along the bottom. Press play and the spec streams in character by character. The parser materializes it as it arrives, so headings, cards, text, and badges appear as their props complete, with skeleton placeholders standing in until they do.

The tabs at the top switch between three specs — Basic Types, Card Layout, and Mixed Components — and picking one restarts the stream against that spec. All three draw on the same four registered names, so what changes between them is the shape of the element tree, not the components. The transport scrubs to any point in the stream and plays at 1x, 2x, or 4x.

How it is built

The example is one Angular file and an application config. Open the Code tab to read them in place.

The application config registers nothing

Most applications register the registry once, at the root. This one calls provideRender() with an empty config.

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRender } from '@threadplane/render';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideRender({}),
  ],
};

The registry travels as an input on the render surface instead, which is the form that takes precedence anyway.

What a registered component receives

Every registered component is a plain standalone Angular component. content is the element's own prop from the spec; the five framework inputs at the bottom of the class are what the renderer supplies for every element it mounts.

registry.component.ts — a registered component
@Component({
  selector: 'demo-text',
  standalone: true,
  styles: `.t { margin: 0; font-size: 13px; line-height: 1.55; color: var(--ds-text-secondary, #c8c8c8); }`,
  template: `
    @if (displayContent()) {
      <p class="t">{{ displayContent() }}</p>
    } @else if (loading()) {
      <div class="sr-skeleton" style="height: 11px; width: 100%; margin: 3px 0;"></div>
      <div class="sr-skeleton" style="height: 11px; width: 66%; margin: 3px 0;"></div>
    }
  `,
})
class DemoTextComponent {
  readonly content = input<unknown>('');
  readonly displayContent = computed(() => toDisplayText(this.content()));
  readonly childKeys = input<string[]>([]);
  readonly spec = input<Spec | null>(null);
  readonly bindings = input<Record<string, string>>({});
  readonly emit = input<(event: string) => void>(() => {});
  readonly loading = input(false);
}

loading is true while the spec is still streaming, which is what selects the skeleton branch in the template.

Rendering children

An element's children arrive as childKeys, and the whole spec arrives with them. That pair is everything <render-element> needs to mount a child by key, which is how a tree of any depth renders.

registry.component.ts — recursive children
@Component({
  selector: 'demo-heading',
  standalone: true,
  imports: [RenderElementComponent],
  styles: `.h { margin: 0 0 0.5rem; font-size: 1.05rem; font-weight: 700; color: var(--ds-text-primary, #f5f5f5); }`,
  template: `
    @if (displayContent()) {
      <h2 class="h">{{ displayContent() }}</h2>
    } @else if (loading()) {
      <div class="sr-skeleton" style="height: 18px; width: 12rem; margin-bottom: 0.5rem;"></div>
    }
    @for (key of childKeys(); track key) {
      <render-element [elementKey]="key" [spec]="spec()!" />
    }
  `,
})
class DemoHeadingComponent {
  readonly content = input<unknown>('');
  readonly displayContent = computed(() => toDisplayText(this.content()));
  readonly childKeys = input<string[]>([]);
  readonly spec = input<Spec | null>(null);
  readonly bindings = input<Record<string, string>>({});
  readonly emit = input<(event: string) => void>(() => {});
  readonly loading = input(false);
}

A component renders children only if its template asks for them: the badge in the same file declares childKeys and never reads it.

The registry

defineAngularRegistry() takes a plain object whose keys are type names and whose values are component classes.

registry.component.ts — the registry and the store
protected readonly registry = defineAngularRegistry({
  Text: DemoTextComponent,
  Heading: DemoHeadingComponent,
  Badge: DemoBadgeComponent,
  Card: DemoCardComponent,
});
 
protected readonly store = signalStateStore({});

The four keys are exactly the type strings the three specs use, and signalStateStore({}) is the state a spec would bind against with $bindState.

Tip: Type names are a contract

The keys are the identifiers a spec author, or a model producing specs, has to write. Keep them descriptive and stable — stat-card rather than c2 — because renaming one silently stops resolving every element that still uses the old name.

Mounting the spec

<render-spec> receives the materialized spec, the registry, the store, and a loading flag driven by the simulator.

registry.component.ts — the render surface
<div primary>
  <div class="cap">Live Render Output</div>
  @if (simulator.spec(); as renderedSpec) {
    <render-spec [spec]="renderedSpec" [registry]="registry" [store]="store" [loading]="simulator.playing()" />
  } @else {
    <div class="placeholder">Press play to start streaming…</div>
  }
</div>

loading reaches every mounted component as its own loading input, which is how the skeletons know the stream is still running.

The agent is not in the render path

The capability also ships a backend, and it is worth being clear about what it does not do. This single-node LangGraph agent answers questions about registries; it never produces the specs on screen, which the example streams locally.

graph.py
"""
Render Registry Graph
 
A LangGraph StateGraph that explains defineAngularRegistry() for mapping
type strings to Angular component classes.
"""
 
from pathlib import Path
from langgraph.graph import StateGraph, MessagesState, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage
 
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
 
 
def build_registry_graph():
    """
    Constructs a graph that explains defineAngularRegistry.
 
    The agent responds with guidance on creating and using component
    registries for render spec type resolution.
    """
    llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
 
    async def generate(state: MessagesState) -> dict:
        system_prompt = (PROMPTS_DIR / "registry.md").read_text()
        messages = [SystemMessage(content=system_prompt)] + state["messages"]
        response = await llm.ainvoke(messages)
        return {"messages": [response]}
 
    graph = StateGraph(MessagesState)
    graph.add_node("generate", generate)
    graph.set_entry_point("generate")
    graph.add_edge("generate", END)
 
    return graph.compile()
 
 
graph = build_registry_graph()

What an entry holds

This is an illustrative registry, not the one the demo builds; it exists to show the shape an entry can take. A registered value can be a bare component class or a full entry object. defineAngularRegistry() normalizes both into the same shape: a component, a guaranteed fallback, and the optional schema and description an entry may declare.

// component imports omitted
import { defineAngularRegistry } from '@threadplane/render';
 
const registry = defineAngularRegistry({
  Text: TextComponent,
  Card: {
    component: CardComponent,
    fallback: CardSkeletonComponent,
    description: 'A titled container that renders its children.',
  },
});
 
registry.getEntry('Text')?.component; // TextComponent
registry.getEntry('Text')?.fallback;  // DefaultFallbackComponent
registry.getEntry('Card')?.fallback;  // CardSkeletonComponent
registry.getEntry('Unknown');         // undefined
registry.names();                     // ['Text', 'Card']

getEntry(name) returns the normalized entry or undefined, and names() lists every registered name.

Fallbacks and unregistered types

An element whose type is not registered has no entry at all, so nothing mounts and the element renders nothing. When the type is registered, the entry's fallback fills the gap while a prop is still resolving to undefined, or while a declared schema does not yet validate the resolved props. Once the real component mounts it stays mounted: the switch is one-way per element instance, so a prop that later becomes undefined never reverts the element to its fallback. A repeating element is gated one row at a time — each mount is judged on the props it resolved in its own item scope, so a row that is still missing a value shows the fallback while its ready siblings show the real component.

The component input contract

Alongside the element's own props, the renderer passes a fixed set of framework inputs.

InputTypeDescription
emit(event: string) => voidFires the element's on[event] handler bindings
bindingsRecord<string, string>Prop name to the absolute state path it is bound to
loadingbooleanWhether the spec is currently streaming
childKeysstring[]Element keys for recursive child rendering
specSpecThe full spec, for resolving those child keys

Custom props are resolved from the element and spread alongside them. Given this element:

{
  "type": "Text",
  "props": {
    "label": "Hello",
    "size": "large"
  }
}

the component receives label and size as inputs.

Every input is then filtered down to the names the target component actually declares, so a component that only wants label may declare only label and ignore the rest.

Tip: Give every input a default

Props arrive while the spec is still streaming, so a prop may be missing on the first mount and appear later. A default value keeps that first render valid.

Two-way bindings

When a prop uses $bindState, the prop resolves to the current value at that path and the bindings input receives the mapping from prop name to path. Given this element:

{
  "type": "Input",
  "props": {
    "value": { "$bindState": "/form/email" },
    "label": "Email"
  }
}

the component receives value resolved to the stored value and bindings set to { value: '/form/email' }. To write back, read the path out of bindings and set it on the store.

Talking back through the render host

injectRenderHost() gives a mounted component the element-scoped host, which is the supported way to write state, fire events, and announce a result. Inside a repeat the host is scoped to the row, so an event it fires resolves { "$item": … } action params against that row's item.

import { Component, input } from '@angular/core';
import { injectRenderHost } from '@threadplane/render';
 
@Component({
  selector: 'app-approval',
  standalone: true,
  template: `<button (click)="approve()">Approve</button>`,
})
export class ApprovalComponent {
  readonly bindings = input<Record<string, string>>({});
 
  private readonly host = injectRenderHost();
 
  approve() {
    const path = this.bindings()['approved'];
    if (path) {
      this.host.set(path, true);
    }
    this.host.emit('approved');
    this.host.result({ approved: true });
  }
}

set(path, value) writes to the render state store at a JSON Pointer path, emit(event, payload?) routes to the element's on handlers, and result(value) surfaces the value to the host as a render result event.

Providing the registry

There are two places a registry can come from. The application root is the usual one:

import { ApplicationConfig } from '@angular/core';
import { defineAngularRegistry, provideRender } from '@threadplane/render';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideRender({
      registry: defineAngularRegistry({
        Text: TextComponent,
        Card: CardComponent,
      }),
    }),
  ],
};

The other is the registry input on the surface itself, which is what the example uses so that one page can render with a registry of its own:

<render-spec [spec]="spec()" [registry]="registry" />

<render-spec> resolves its registry in a fixed order: the input first, then the config from provideRender(), then a registry supplied through provideViews(), and finally an empty registry that resolves nothing.

What's Next

Looking for something specific?