Page actions

React Component Registry preview

Decide which component names a generated layout may use. A registry maps a plain name such as Badge to an authored React view, giving your application control of behavior and presentation.

The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.

Register the view contract

Each registry component receives RenderViewProps: resolved values in props, optional bindings, an element key, loading state, and children. A container must render its children exactly where they should appear. Keep the registry stable and define an intentional fallback for types your application does not provide.

import type { ReactRenderRegistry } from '@threadplane/react/render';
 
export const tripViews: ReactRenderRegistry = {
  Heading: ({ props }) => <h2>{String(props['text'] ?? '')}</h2>,
  Card: ({ children }) => <section className="trip-card">{children}</section>,
};

The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.

Try each registry mode

Choose Run, select Basic Types, Card Layout or Mixed Components, and press Finish. Badge display selects Registered, Omitted or Fallback. Registered maps Badge to its view. Omitted leaves Badge out of the registry, so the native renderer leaves it out of the view. Fallback uses the same reduced registry and supplies an authored component to display its label.

Changing registry mode does not rewrite the JSON or move playback. Your choice survives Play, Pause, Playback position, Finish, Reset and sample changes. Reloading restores Registered. No agent requests or saved browser state are used.

specs.ts
export const REGISTRY_SAMPLES: readonly {
  readonly label: string;
  readonly json: string;
}[] = [
  {
    label: 'Basic Types',
    json: JSON.stringify(
      {
        root: 'root',
        elements: {
          root: {
            type: 'Heading',
            props: { content: 'Basic Component Types' },
            children: ['desc', 'badge'],
          },
          desc: {
            type: 'Text',
            props: {
              content:
                'Each type string resolves to a registered React view at render time.',
            },
          },
          badge: {
            type: 'Badge',
            props: { label: 'Registered' },
          },
        },
      },
      null,
      2
    ),
  },
  {
    label: 'Card Layout',
    json: JSON.stringify(
      {
        root: 'root',
        elements: {
          root: {
            type: 'Card',
            props: { title: 'Card Layout Demo' },
            children: ['heading', 'body', 'tag'],
          },
          heading: {
            type: 'Heading',
            props: { content: 'Inside a Card' },
          },
          body: {
            type: 'Text',
            props: {
              content:
                'Cards display the children supplied by the native renderer.',
            },
          },
          tag: {
            type: 'Badge',
            props: { label: 'Nested' },
          },
        },
      },
      null,
      2
    ),
  },
  {
    label: 'Mixed Components',
    json: JSON.stringify(
      {
        root: 'root',
        elements: {
          root: {
            type: 'Heading',
            props: { content: 'All Registered Types' },
            children: ['card1', 'card2'],
          },
          card1: {
            type: 'Card',
            props: { title: 'First Section' },
            children: ['text1', 'badge1'],
          },
          text1: {
            type: 'Text',
            props: { content: 'Text inside the first card section.' },
          },
          badge1: {
            type: 'Badge',
            props: { label: 'Section 1' },
          },
          card2: {
            type: 'Card',
            props: { title: 'Second Section' },
            children: ['text2', 'badge2'],
          },
          text2: {
            type: 'Text',
            props: { content: 'Text inside the second card section.' },
          },
          badge2: {
            type: 'Badge',
            props: { label: 'Section 2' },
          },
        },
      },
      null,
      2
    ),
  },
];

Supply components and native children

The host owns two stable frozen maps: the complete Heading, Text, Card and Badge registry, and a map with Badge omitted. RenderSpec receives the selected registry and the optional fallback. React uses a plain map of view components; this preview does not require Angular registry helpers.

A registered view receives read-only resolved props, elementKey, loading and children. Heading and Card display the children supplied by RenderSpec exactly once. Text and Badge are leaves. The authored fallback displays a literal label and uses the same loading flag. It is intentionally demonstrated only for the known Badge type omitted from the selected map.

views.tsx
import type {
  ReactRenderRegistry,
  RenderViewProps,
} from '@threadplane/react/render';
 
function literal(value: unknown): string {
  return typeof value === 'string' ? value : '';
}
function Heading({ props, children, loading }: RenderViewProps) {
  return (
    <div className="render-heading">
      <h3 aria-busy={loading}>
        {literal(props.content) || (loading ? '…' : '')}
      </h3>
      {children}
    </div>
  );
}
function Text({ props, loading }: RenderViewProps) {
  return (
    <p aria-busy={loading}>{literal(props.content) || (loading ? '…' : '')}</p>
  );
}
function Card({ props, children, loading }: RenderViewProps) {
  return (
    <article className="render-card">
      <h4 aria-busy={loading}>
        {literal(props.title) || (loading ? '…' : '')}
      </h4>
      {children}
    </article>
  );
}
function Badge({ props, loading }: RenderViewProps) {
  return (
    <span data-registry-badge className="render-badge" aria-busy={loading}>
      {literal(props.label) || (loading ? '…' : '')}
    </span>
  );
}
export const localRegistry: ReactRenderRegistry = Object.freeze({
  Heading,
  Text,
  Card,
  Badge,
});
 
export const withoutBadgeRegistry: ReactRenderRegistry = Object.freeze({
  Heading,
  Text,
  Card,
});
export function FallbackBadge({ props, elementKey, loading }: RenderViewProps) {
  return (
    <p
      data-registry-fallback
      data-fallback-key={elementKey}
      aria-busy={loading}
    >
      Fallback: {literal(props.label) || (loading ? '…' : '')}
    </p>
  );
}
app.tsx
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { RenderSpec } from '@threadplane/react/render';
import type { Playback } from './playback';
import { localRegistry, withoutBadgeRegistry, FallbackBadge } from './views';
 
export function RegistryDemo({
  playback,
  samples,
  onReady,
}: {
  readonly playback: Playback;
  readonly samples: readonly string[];
  readonly onReady: () => void;
}) {
  const [mode, setMode] = useState('registered');
  const state = useSyncExternalStore(
    playback.subscribe,
    playback.getSnapshot,
    playback.getSnapshot
  );
  useEffect(onReady, [onReady]);
  const phase =
    state.phase === 'error'
      ? 'Unavailable'
      : state.phase[0].toUpperCase() + state.phase.slice(1);
  return (
    <ExampleLayout kind="render" className="spec-demo">
      <header>
        <h1>Component Registry</h1>
      </header>
      <p>
        Choose how the registry handles Badge elements. The native renderer can
        display a registered view, omit it, or use an authored fallback.
      </p>
      <section aria-label="Registry controls" className="controls">
        <label>
          Badge display
          <select
            value={mode}
            onChange={(event) => {
              const value = event.target.value;
              if (['registered', 'omitted', 'fallback'].includes(value))
                setMode(value);
            }}
          >
            <option value="registered">Registered</option>
            <option value="omitted">Omitted</option>
            <option value="fallback">Fallback</option>
          </select>
        </label>
        <p>
          Registry choice survives playback and sample changes. Reloading
          restores Registered.
        </p>
      </section>
      <section aria-label="Playback controls" className="controls">
        <label>
          Sample
          <select
            value={state.selected}
            onChange={(event) => playback.select(Number(event.target.value))}
          >
            {samples.map((label, index) => (
              <option key={index} value={index}>
                {label}
              </option>
            ))}
          </select>
        </label>
        <div className="buttons">
          <button
            type="button"
            disabled={state.phase === 'error'}
            onClick={state.playing ? playback.pause : playback.play}
          >
            {state.playing ? 'Pause' : 'Play'}
          </button>
          <button
            type="button"
            disabled={state.phase === 'error'}
            onClick={playback.finish}
          >
            Finish
          </button>
          <button type="button" onClick={playback.reset}>
            Reset
          </button>
        </div>
        <label>
          Playback position
          <input
            type="range"
            min={0}
            max={state.total}
            step={1}
            value={state.position}
            disabled={state.phase === 'error'}
            onChange={(event) => playback.seek(Number(event.target.value))}
          />
        </label>
        <p role="status">
          {phase} · {state.position} characters
        </p>
        {state.phase === 'error' && (
          <p role="alert">
            This sample could not be displayed. Choose another sample to
            continue.
          </p>
        )}
      </section>
      <div className="panels">
        <section aria-label="Render output">
          <h2>Render output</h2>
          {state.spec ? (
            <RenderSpec
              spec={state.spec}
              registry={
                mode === 'registered' ? localRegistry : withoutBadgeRegistry
              }
              fallback={mode === 'fallback' ? FallbackBadge : undefined}
              loading={state.position < state.total}
            />
          ) : (
            <p className="empty">Play a sample to see its view.</p>
          )}
        </section>
        <section aria-label="Streaming JSON">
          <h2>Streaming JSON</h2>
          <pre>{state.rawJson}</pre>
        </section>
      </div>
    </ExampleLayout>
  );
}

Admit the authored source before playback

Registry selection and source admission have separate jobs. This example admits the four authored types and literal display props before playing a sample. It rejects unknown types, expressions and executable fields. Then it copies bounded partial data without freezing the parser's mutable tree.

projection.ts
import type {
  RenderElementData,
  RenderSpecData,
} from '@threadplane/react/render';
 
const TYPES = ['Heading', 'Text', 'Card', 'Badge'];
const ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const MAX_NODES = 32;
const MAX_TEXT = 16 * 1024;
 
function invalid(): never {
  throw new TypeError('Invalid local render data.');
}
 
function record(input: unknown): Record<string, unknown> {
  if (
    input === null ||
    typeof input !== 'object' ||
    Array.isArray(input) ||
    ![null, Object.prototype].includes(Object.getPrototypeOf(input)) ||
    Object.getOwnPropertySymbols(input).length
  )
    invalid();
  const entries = Object.entries(Object.getOwnPropertyDescriptors(input));
  if (entries.length > MAX_NODES) invalid();
  const result = Object.create(null) as Record<string, unknown>;
  for (const [key, descriptor] of entries) {
    if (!descriptor.enumerable || !('value' in descriptor)) invalid();
    Object.defineProperty(result, key, {
      value: descriptor.value,
      enumerable: true,
    });
  }
  return result;
}
 
function fields(
  input: Record<string, unknown>,
  allowed: readonly string[]
): void {
  if (Object.keys(input).some((key) => !allowed.includes(key))) invalid();
}
 
function references(input: unknown, complete: boolean): readonly string[] {
  if (input === undefined && !complete) return [];
  if (
    !Array.isArray(input) ||
    Object.getPrototypeOf(input) !== Array.prototype ||
    input.length > MAX_NODES ||
    Object.getOwnPropertySymbols(input).length
  )
    invalid();
  const descriptors = Object.getOwnPropertyDescriptors(input);
  for (const [key, descriptor] of Object.entries(descriptors))
    if (
      !('value' in descriptor) ||
      (key !== 'length' && !/^(0|[1-9][0-9]*)$/.test(key))
    )
      invalid();
  const result: string[] = [];
  for (let index = 0; index < input.length; index++) {
    const value = descriptors[String(index)]?.value;
    if ((value === undefined || value === '') && !complete) continue;
    if (typeof value !== 'string' || !ID.test(value) || result.includes(value))
      invalid();
    result.push(value);
  }
  return result;
}
 
function project(input: unknown, complete: boolean): RenderSpecData | null {
  if (input === null && !complete) return null;
  const source = record(input);
  fields(source, ['root', 'elements']);
  if (
    source.root === undefined ||
    source.root === '' ||
    source.elements === undefined
  ) {
    if (complete) invalid();
    return null;
  }
  if (typeof source.root !== 'string' || !ID.test(source.root)) invalid();
  const sourceElements = record(source.elements);
  const elements = Object.create(null) as Record<string, RenderElementData>;
  const childLists = new Map<string, readonly string[]>();
  let textSize = 0;
  for (const [key, inputElement] of Object.entries(sourceElements)) {
    if (!ID.test(key)) invalid();
    if (inputElement === undefined && !complete) continue;
    const element = record(inputElement);
    fields(element, ['type', 'props', 'children']);
    if (element.type === undefined && !complete) continue;
    if (typeof element.type !== 'string') invalid();
    if (!TYPES.includes(element.type)) {
      if (complete) invalid();
      continue;
    }
    const rawProps = element.props === undefined ? {} : record(element.props);
    fields(rawProps, [
      element.type === 'Card'
        ? 'title'
        : element.type === 'Badge'
        ? 'label'
        : 'content',
    ]);
    const props = Object.create(null) as Record<string, string>;
    for (const [prop, value] of Object.entries(rawProps)) {
      if (value === undefined && !complete) continue;
      if (typeof value !== 'string' || (textSize += value.length) > MAX_TEXT)
        invalid();
      Object.defineProperty(props, prop, { value, enumerable: true });
    }
    const children = Object.hasOwn(element, 'children')
      ? references(element.children, complete)
      : undefined;
    if (children) childLists.set(key, children);
    Object.defineProperty(elements, key, {
      value: { type: element.type, props: Object.freeze(props) },
      enumerable: true,
    });
  }
  for (const [key, children] of childLists) {
    if (complete && children.some((child) => !Object.hasOwn(elements, child)))
      invalid();
    Object.defineProperty(elements[key], 'children', {
      value: Object.freeze(
        children.filter((child) => Object.hasOwn(elements, child))
      ),
      enumerable: true,
    });
  }
  const heights = new Map<string, number>();
  const active = new Set<string>();
  function height(key: string): number {
    const known = heights.get(key);
    if (known !== undefined) return known;
    if (active.has(key)) invalid();
    active.add(key);
    let result = 1;
    for (const child of elements[key].children ?? [])
      result = Math.max(result, 1 + height(child));
    active.delete(key);
    if (result > 16) invalid();
    heights.set(key, result);
    return result;
  }
  for (const key of Object.keys(elements)) {
    height(key);
    Object.freeze(elements[key]);
  }
  if (!Object.hasOwn(elements, source.root)) {
    if (complete) invalid();
    return null;
  }
  return Object.freeze({
    root: source.root,
    elements: Object.freeze(elements),
  });
}
 
/** Copy the known display subset; the incremental parser retains its own mutable tree. */
export function projectPartialSpec(input: unknown): RenderSpecData | null {
  return project(input, false);
}
 
/** Admit a complete authored local sample before any playback begins. */
export function validateCompleteSpec(input: unknown): RenderSpecData {
  return project(input, true) ?? invalid();
}

The private playback owner rebuilds its parser on seek and revokes obsolete callbacks on Pause, Reset, selection changes and page exit. Complete requires the finished parser output to match the admitted source. Playback never changes registry choice.

playback.ts
import type { RenderSpecData } from '@threadplane/react/render';
import { createPartialJsonParser, materialize } from '@cacheplane/partial-json';
import { projectPartialSpec, validateCompleteSpec } from './projection';
 
export interface PlaybackSample {
  readonly label: string;
  readonly json: string;
}
export interface FrameScheduler {
  request(callback: FrameRequestCallback): number;
  cancel(id: number): void;
}
export interface PlaybackSnapshot {
  readonly selected: number;
  readonly label: string;
  readonly position: number;
  readonly total: number;
  readonly phase: 'paused' | 'playing' | 'complete' | 'error';
  readonly playing: boolean;
  readonly rawJson: string;
  readonly spec: RenderSpecData | null;
}
export interface Playback {
  getSnapshot(): PlaybackSnapshot;
  subscribe(listener: () => void): () => void;
  play(): void;
  pause(): void;
  seek(position: number): void;
  select(index: number): void;
  finish(): void;
  reset(): void;
  dispose(): void;
}
 
export function createPlayback(
  samples: readonly PlaybackSample[],
  scheduler: FrameScheduler = {
    request: (callback) => requestAnimationFrame(callback),
    cancel: (id) => cancelAnimationFrame(id),
  }
): Playback {
  const owned = samples.map((sample) => ({
    label: sample.label,
    json: sample.json,
  }));
  const listeners = new Set<() => void>();
  let live = true;
  let generation = 0;
  let frame: number | undefined;
  let parser = createPartialJsonParser();
  let admitted: RenderSpecData | null = null;
  let snapshot: PlaybackSnapshot;
 
  function revoke(): number {
    generation++;
    if (frame !== undefined) scheduler.cancel(frame);
    frame = undefined;
    return generation;
  }
 
  function publish(next: PlaybackSnapshot): void {
    snapshot = Object.freeze(next);
    const current = generation;
    for (const listener of [...listeners]) {
      if (!live || current !== generation) break;
      if (!listeners.has(listener)) continue;
      try {
        listener();
      } catch {
        /* A subscriber cannot take ownership of playback. */
      }
    }
  }
 
  function initialize(selected: number, notify: boolean): void {
    parser = createPartialJsonParser();
    admitted = null;
    const sample = owned[selected];
    try {
      if (!sample || sample.json.length > 16 * 1024)
        throw Error('Invalid sample');
      // Incremental parsers can retain malformed input as streaming forever.
      admitted = validateCompleteSpec(JSON.parse(sample.json));
    } catch {
      /* Only authored, bounded complete JSON may be played. */
    }
    const next: PlaybackSnapshot = {
      selected,
      label: sample?.label ?? 'Unavailable sample',
      position: 0,
      total: sample?.json.length ?? 0,
      phase: admitted ? 'paused' : 'error',
      playing: false,
      rawJson: '',
      spec: null,
    };
    if (notify) publish(next);
    else snapshot = Object.freeze(next);
  }
 
  function advance(position: number, playing: boolean): void {
    const source = owned[snapshot.selected].json;
    try {
      parser.push(source.slice(snapshot.position, position));
      const complete = position === source.length;
      if (complete) parser.finish();
      const value = parser.root ? materialize(parser.root) : null;
      const spec = complete
        ? validateCompleteSpec(value)
        : projectPartialSpec(value);
      if (
        complete &&
        (parser.root?.status !== 'complete' ||
          JSON.stringify(spec) !== JSON.stringify(admitted))
      )
        throw Error('Incomplete sample');
      publish({
        ...snapshot,
        position,
        rawJson: source.slice(0, position),
        spec,
        playing: playing && !complete,
        phase: complete ? 'complete' : playing ? 'playing' : 'paused',
      });
    } catch {
      revoke();
      publish({
        ...snapshot,
        phase: 'error',
        playing: false,
        rawJson: '',
        spec: null,
      });
    }
  }
 
  function schedule(current: number): void {
    if (
      !live ||
      generation !== current ||
      !snapshot.playing ||
      frame !== undefined
    )
      return;
    frame = scheduler.request(() => {
      if (!live || generation !== current || !snapshot.playing) return;
      frame = undefined;
      advance(Math.min(snapshot.total, snapshot.position + 4), true);
      schedule(current);
    });
  }
 
  function seek(position: number): void {
    if (!live || !Number.isFinite(position) || !admitted) return;
    revoke();
    parser = createPartialJsonParser();
    const target = Math.max(0, Math.min(snapshot.total, Math.floor(position)));
    // Rebuild privately, then publish once so a subscriber cannot interrupt a reset.
    snapshot = Object.freeze({
      ...snapshot,
      position: 0,
      rawJson: '',
      spec: null,
      playing: false,
      phase: 'paused',
    });
    advance(target, false);
  }
 
  initialize(0, false);
  return {
    getSnapshot: () => snapshot,
    subscribe(listener) {
      if (live) listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    play() {
      if (!live || !admitted || snapshot.playing) return;
      const current = revoke();
      if (snapshot.phase === 'complete') {
        parser = createPartialJsonParser();
        snapshot = Object.freeze({
          ...snapshot,
          position: 0,
          rawJson: '',
          spec: null,
        });
      }
      publish({ ...snapshot, playing: true, phase: 'playing' });
      schedule(current);
    },
    pause() {
      if (!live || !snapshot.playing) return;
      revoke();
      publish({ ...snapshot, playing: false, phase: 'paused' });
    },
    seek,
    select(index) {
      if (
        !live ||
        !Number.isInteger(index) ||
        index < 0 ||
        index >= owned.length
      )
        return;
      revoke();
      initialize(index, true);
    },
    finish() {
      seek(snapshot.total);
    },
    reset() {
      seek(0);
    },
    dispose() {
      if (!live) return;
      live = false;
      revoke();
      listeners.clear();
      snapshot = Object.freeze({
        ...snapshot,
        playing: false,
        phase: snapshot.phase === 'playing' ? 'paused' : snapshot.phase,
      });
    },
  };
}

Source build and verification

From a Threadplane checkout with dependencies installed:

npx nx build cockpit-render-registry-react
npx nx e2e cockpit-render-registry-react

The isolated build installs local React, core and content artifacts with root-locked presentation dependencies. Browser coverage exercises all three registry modes, native fallback props and loading, playback retention, cleanup and narrow screens. Select Angular to read and run its existing registry implementation.

Looking for something specific?