Page actions

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.

app.config.ts
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.

element-rendering.component.ts — registry and store
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.

element-rendering.component.ts — the render-spec host
<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.

element-rendering.component.ts — a view component that renders its 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);
}

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.

Note: Framework inputs are filtered to what the component declares

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.

element-rendering.component.ts — toggling a bound state path
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

InputTypeDefaultDescription
specSpec | nullnullThe json-render spec to render. When null, or when it has no root, nothing is rendered.
registryAngularRegistry | undefinedundefinedComponent registry mapping element type names to Angular components.
storeStateStore | undefinedundefinedState store that prop expressions and visibility conditions read from.
functionsRecord<string, ComputedFunction> | undefinedundefinedComputed functions available to $computed prop expressions.
handlersRecord<string, (params: Record<string, unknown>) => unknown | Promise<unknown>> | undefinedundefinedAction handlers invoked when a rendered component calls emit().
loadingbooleanfalseWhether the spec is still streaming. Passed to every rendered component as its loading input.
telemetryboolean | undefinedundefinedSet false to disable automatic development collection for this render tree.

Outputs

OutputTypeDescription
eventsRenderEventEvery 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:

  1. The component input, when it is set.
  2. RENDER_CONFIG, the configuration registered by provideRender().
  3. For registry only, the VIEW_REGISTRY token registered by provideViews(), converted with toRenderRegistry().
  4. A last-resort fallback. For store, an internal signalStateStore() built from spec.state. For registry, 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

RenderSpecComponentclass

Top-level entry point for rendering a json-render spec. Accepts the spec, registry, store, functions, handlers, and loading as inputs. Provides `RENDER_CONTEXT` to child `RenderElementComponent` instances via `viewProviders`. Falls back to `RENDER_CONFIG` (from `provideRender()`) for registry and store defaults when inputs are not provided.

Properties

ParameterTypeDescription
_contextSignal<RenderContext>The RenderContext provided to children via viewProviders.
eventsOutputEmitterRef<RenderEvent>
functionsInputSignal<Record<string, ComputedFunction> | undefined>
handlersInputSignal<Record<string, (params: Record<string, unknown>) => unknown> | undefined>
loadingInputSignal<boolean>
registryInputSignal<AngularRegistry | undefined>
specInputSignal<Spec | null>
storeInputSignal<StateStore | undefined>
telemetryInputSignal<boolean | undefined>Disable automatic development collection for this render tree.

Methods

ngOnInit(): void

A callback method that is invoked immediately after the default change detector has checked the directive's data-bound properties for the first time, and before any of the view or content children have been checked. It is invoked only once when the directive is instantiated.

Examples

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

Looking for something specific?