RenderSpecComponent
RenderSpecComponent is the entry point for rendering a @json-render/core spec as an Angular component tree. It takes the spec plus the pieces needed to interpret it — a registry of element types, a state store, computed functions and action handlers — and mounts the root element. The running example streams a spec in one character at a time and renders it live, so every input on this page has a visible effect you can watch.
What the demo does
The Run tab is split in two. On the left is the live render output; on the right is the raw JSON as it arrives, plus a checkbox. Press play on the transport bar at the bottom and the spec streams in character by character, materializing into rendered elements as soon as each one is complete.
Three specs sit behind the tabs at the top. "Parent + Children" is a heading with two text children. "Deep Nesting" is a card wrapping a card wrapping a text element. "Visibility" adds an element whose visible condition reads /showDetail from the store, which is what the checkbox toggles: uncheck it and that element disappears without the spec changing at all.
How it is built
The agent is not in the render path. The spec arrives from a local streaming simulator rather than a model, which keeps the moving parts down to the spec, the registry and the store. There is a graph.py in the capability, but this Angular application never calls it — its app.config.ts registers nothing but the render feature.
The application configuration
provideRender() registers the shared RENDER_CONFIG token and the internal lifecycle service. This example passes an empty options object, because it supplies the registry and store per instance instead.
import { ApplicationConfig } from '@angular/core';
import { provideRender } from '@threadplane/render';
export const appConfig: ApplicationConfig = {
providers: [
provideRender({}),
],
};Registering element types and state
defineAngularRegistry() maps the type string of an element to the Angular component that renders it. The three types in this demo are Text, Heading and Card. Alongside it, signalStateStore() creates the store that visibility conditions read from, seeded with showDetail: true.
protected readonly registry = defineAngularRegistry({
Text: DemoTextComponent,
Heading: DemoHeadingComponent,
Card: DemoCardComponent,
});
protected readonly store = signalStateStore({ showDetail: true });
/** Angular signal tracking store value for template reactivity. */
protected readonly showDetail = signal(true);
constructor() {
// Sync store changes to Angular signal for change detection
this.store.subscribe(() => {
this.showDetail.set(this.store.get('/showDetail') as boolean ?? true);
});The constructor subscribes to the store and mirrors /showDetail into an Angular signal, which is the value the checkbox binds against.
Mounting the spec
<render-spec> takes the partially materialized spec, the registry, the store, and a loading flag wired to whether the simulator is still playing. While the spec is null the template shows a placeholder instead.
<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 is passed down to every rendered component as an input of the same name, which is how the view components below know to draw skeletons instead of empty text.
Rendering children recursively
This is the part the API surface does not make obvious: <render-spec> renders exactly one element, the one named by spec.root. Children are rendered because a view component asks for them. Every mounted component receives a childKeys input holding element.children, and a component that wants to render them loops over the keys and mounts a <render-element> for each.
@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);
}DemoTextComponent in the same file declares childKeys but never loops over it, which is why text elements are leaves in this demo even when a spec gives them children.
RenderElementComponent passes five framework inputs — bindings, emit, loading, childKeys and spec — alongside the element's resolved props, then drops any key the target component does not declare, so a simple view component is not warned about inputs it ignores. The view components in this example declare all five.
Driving visibility from the store
The checkbox writes to the store, not to the spec. RenderElementComponent derives each element's visibility from a computed that reads the store, so a write to /showDetail re-evaluates the { $state: '/showDetail' } condition on the conditional element and mounts or unmounts it.
protected onToggleDetail(_event: Event): void {
const current = this.showDetail();
this.store.set('/showDetail', !current);
this.showDetail.set(!current);
}Import
import { RenderSpecComponent } from '@threadplane/render';The selector is render-spec, and the component is standalone, so add it to a component's imports array.
Inputs
| Input | Type | Default | Description |
|---|---|---|---|
spec | Spec | null | null | The json-render spec to render. When null, or when it has no root, nothing is rendered. |
registry | AngularRegistry | undefined | undefined | Component registry mapping element type names to Angular components. |
store | StateStore | undefined | undefined | State store that prop expressions and visibility conditions read from. |
functions | Record<string, ComputedFunction> | undefined | undefined | Computed functions available to $computed prop expressions. |
handlers | Record<string, (params: Record<string, unknown>) => unknown | Promise<unknown>> | undefined | undefined | Action handlers invoked when a rendered component calls emit(). |
loading | boolean | false | Whether the spec is still streaming. Passed to every rendered component as its loading input. |
telemetry | boolean | undefined | undefined | Set false to disable automatic development collection for this render tree. |
Outputs
| Output | Type | Description |
|---|---|---|
events | RenderEvent | Every render event from this tree: lifecycle events for the spec and for elements that opt in, stateChange on each store write, handler after an action handler runs, and result when a component reports a value. |
Handlers are wrapped whether they arrive as an input or from provideRender(), so a handler event is emitted after each one runs, including after a returned promise settles.
Resolution order
registry, store, functions and handlers are each resolved independently, in this order:
- The component input, when it is set.
RENDER_CONFIG, the configuration registered byprovideRender().- For
registryonly, theVIEW_REGISTRYtoken registered byprovideViews(), converted withtoRenderRegistry(). - A last-resort fallback. For
store, an internalsignalStateStore()built fromspec.state. Forregistry, an empty registry, in which case no element type resolves and nothing renders.
Because each is resolved on its own, a per-instance override of one leaves the rest on their global defaults:
provideRender({
registry: defaultRegistry,
store: globalStore,
handlers: { log: (params) => console.log(params) },
});<!-- registry is overridden here; store and handlers still come from provideRender -->
<render-spec [spec]="spec" [registry]="customRegistry" />The render context
RenderSpecComponent provides a RENDER_CONTEXT token to its children through viewProviders. Every RenderElementComponent under it injects that context to find the registry, the store, and the rest:
interface RenderContext {
registry: AngularRegistry;
store: StateStore;
functions?: Record<string, ComputedFunction>;
handlers?: Record<string, (params: Record<string, unknown>) => unknown | Promise<unknown>>;
emitEvent?: (event: RenderEvent) => void;
loading?: boolean;
}The context is a computed signal, so it is rebuilt when any input changes. A component mounted inside the tree can inject it directly:
import { inject } from '@angular/core';
import { RENDER_CONTEXT } from '@threadplane/render';
const context = inject(RENDER_CONTEXT);
context.store.get('/showDetail');Template behavior
The component template is a single <render-element> for the key named by spec.root:
@if (spec()?.root; as rootKey) {
<render-element [elementKey]="rootKey" [spec]="spec()!" />
}Everything below the root is mounted by view components that render their childKeys, as the example section above shows.
The internal store
When no store is supplied — neither as an input nor through RENDER_CONFIG — the component lazily creates one with signalStateStore() from spec.state. It is created once and reused across spec changes, so it is not rebuilt when a later spec arrives with different state:
const spec: Spec = {
root: 'root',
elements: {
root: { type: 'Text', props: { content: { $state: '/message' } } },
},
state: { message: 'Hello' },
};<!-- No store input: one is created from spec.state -->
<render-spec [spec]="spec" [registry]="registry" />Change detection
The component uses ChangeDetectionStrategy.OnPush, and so does RenderElementComponent. Every reactive path runs through Angular signals: prop expressions, visibility conditions and the resolved context are all computed values.
What's Next
Map element type names to components, with fallbacks and schema gating.
Read and write the store that prop expressions and visibility conditions use.
The spec format: root, elements, props, children and visibility.
Register a registry, store, functions and handlers as application defaults.