Skip to main content

Dynamic Component System

See also: Component Library Guide · Story Engine · Context and Bindings · Architecture Overview

Every non-organism component in libs/stretched-components can be rendered two ways: bound in a template ([props]="…" / [service]="…") or hydrated from stored JSON through a dynamic host. This page covers the engine; per-component authoring rules (required exports, schema conventions, testing) live in Component Library Guide and libs/stretched-components/src/components/CLAUDE.md.

Engine source: libs/stretched-components/src/dynamic-host/ (see its README.md for the authoritative spec).

The three kinds

KindConfig shapeState lives inRendered by
props{ component, kind: 'props', data } — plain JSON-safe objectthe component (props input)DynamicConfigHost
service{ component, kind: 'service', service } — carries a live service instancethe service (signals + mutation methods)DynamicConfigHost
serviced-input{ component, kind: 'serviced-input', data, binding? } — display args only; the Field is not storedthe service, wrapping an injected Field<any>DynamicInputHost

Config types and the two base classes are defined in libs/stretched-components/src/dynamic-host/registry/dynamic-renderable.ts.

  • kind: 'service' configs cannot be persisted (they hold a runtime object) — the story serializer rejects them at save (Story Engine).
  • kind: 'serviced-input' configs get their Field at render time: an explicit [field] input from a hand-written parent, or a stored binding: { story, name } resolved against the application context (Context and Bindings).

Base classes and the props / _props signal pattern

From registry/dynamic-renderable.ts:

export abstract class DynamicComponent<T extends object> {
protected abstract readonly defaults: Required<T>;
readonly props = input<T>();
protected readonly _props = computed<Required<T>>(() => ({ ...this.defaults, ...this.props() }));
}

export abstract class DynamicServicedComponent<S> {
readonly service = input.required<S>();
}
  • props is a signal input; _props is a computed that merges incoming props over module-level defaults (typed Required<T>), so templates read _props().label with no nullish guards. Always call it — _props().
  • Never override props or hand-declare _props; react to prop changes with effect/computed (gotchas 1 and 5 in components/CLAUDE.md).
  • Note that DynamicConfigHost passes stored data raw — the Zod schema is builder-UI metadata only, so _props() can contain whatever was persisted. Guard defensively (gotchas 2, 3, 7).

The registry

Each kind has its own registry sub-file in dynamic-host/registry/:

FileEntry shape (essentials)
props-components.ts{ kind: 'props', type, schema }
service-components.ts{ kind: 'service', type, argsSchema, factory: (args) => service }
input-components.ts{ kind: 'serviced-input', type, argsSchema, produces: VariableType, factory: (field, data) => service }
all-dynamic-components.tscombines the three into ALL_DYNAMIC_COMPONENTS — a combiner, never edited directly

produces declares which VariableType an input reads/writes; it drives the type-filtered bind dropdown in the builders (Context and Bindings).

The registry is delivered by DI: the token lives in src/tokens/dynamic-registry.token.ts (DYNAMIC_REGISTRY_TOKEN), and the app provides ALL_DYNAMIC_COMPONENTS on the routes that render dynamic content (apps/stretched/src/app/app.routes.ts). The registry — not the stored data — is the single source of truth for a component's kind, which is why stored stories don't carry one (Story Engine).

The hosts

DynamicConfigHost (hosts/dynamic-config-host.ts, <sc-dynamic-config-host>) renders props and service configs. Inside a single resolved computed it:

  • looks up the registry entry — an unknown key degrades to a small placeholder ("one bad node must never brick its siblings");
  • delegates serviced-input configs it receives to DynamicInputHost (it can't supply a Field);
  • for props: passes resolveRefs(config.data, context) — this is where { $ref } bindings and { $gen } generators become live values, and what makes props components reactive to variables with no wiring (Context and Bindings);
  • for service: passes the live service instance through.

DynamicInputHost (hosts/dynamic-input-host.ts, <sc-dynamic-input-host>) renders serviced-input configs. Field resolution order is fixed: explicit [field] input wins, else config.binding is resolved via ApplicationContextService; no field → renders nothing. The registry factory(field, data) builds the service; a factory throw (e.g. mid-edit args failing a schema parse) is caught and degrades to an empty preview rather than killing change detection.

Conversion tracker

libs/stretched-components/dynamic-conversion-tracker.json is the authoritative record of which components are converted, pending, or exceptions (with reasons). Converting a component means moving its entry and recording its dynamicKey. pending is tracked debt, not a bug — don't mass-convert. Organisms and infrastructure components (the hosts themselves) are permanent exceptions.

Story-context coverage

dynamic-host/registry/story-context-coverage.spec.ts proves every registered component renders when it arrives through a stored Story (props components as the story root via configFromStoredDynamicConfigHost; serviced-inputs via a ${var} section binding → DynamicInputHost). Three scenarios per component:

  1. Empty {} data — the least a stored doc can carry; must render without throwing.
  2. Builder noise — generated from the component's own schema ([{}] for object arrays, [null] for number arrays…): the mid-edit state a live preview sees; must not crash change detection.
  3. Minimal data — each STORY_FIXTURES entry is the executable definition of what the component actually needs, with a probe asserting real content rendered.

A completeness test fails when a registry key has no fixture — registering a new dynamic component forces adding its story-context coverage.

Authoring configs

Both authoring UIs build these configs from a component's Zod schema — the schema shape is the editor UI (see the schema-field → control table in dynamic-host/README.md):

  • builder/dynamic-component-builder.ts — the form-style single-component builder.
  • page-builder/ — the canvas-style page builder; see Page Builder.