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
| Kind | Config shape | State lives in | Rendered by |
|---|---|---|---|
props | { component, kind: 'props', data } — plain JSON-safe object | the component (props input) | DynamicConfigHost |
service | { component, kind: 'service', service } — carries a live service instance | the service (signals + mutation methods) | DynamicConfigHost |
serviced-input | { component, kind: 'serviced-input', data, binding? } — display args only; the Field is not stored | the 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 theirFieldat render time: an explicit[field]input from a hand-written parent, or a storedbinding: { 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>();
}
propsis a signal input;_propsis a computed that merges incoming props over module-leveldefaults(typedRequired<T>), so templates read_props().labelwith no nullish guards. Always call it —_props().- Never override
propsor hand-declare_props; react to prop changes witheffect/computed(gotchas 1 and 5 incomponents/CLAUDE.md). - Note that
DynamicConfigHostpasses storeddataraw — 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/:
| File | Entry 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.ts | combines 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-inputconfigs it receives toDynamicInputHost(it can't supply aField); - for
props: passesresolveRefs(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 configFromStored → DynamicConfigHost; serviced-inputs via a ${var} section binding → DynamicInputHost). Three scenarios per component:
- Empty
{}data — the least a stored doc can carry; must render without throwing. - 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. - Minimal data — each
STORY_FIXTURESentry is the executable definition of what the component actually needs, with aprobeasserting 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.