a2ui-project / a2ui-project/a2ui

[Proposal] Resolve child components based on schema and not on hardcoded 'child'/'children' keywords. Also improve REF: handling

Abierto
#1,528 8 comentarios 0 reacciones 1 asignado Reclamado por @andrewkolos Ver en GitHub
component: specification P2 type: feature/enhancement
Lenguaje dominante
TypeScript
Estrellas
16.4k
Forks
1.3k
Merge medio
2 d 13 h
PR fusionados (30 d)
134

Descripción

# Make component binders schema-driven for child component resolution (remove hardcoded 'child'/'children' keywords)

## Motivation

Currently, both the Angular binder (`ComponentBinder`) and the Generic Binder (`GenericBinder`) rely on hardcoded property names (`child`, `trigger`, `content`, `children`) to identify which properties represent child component references.
This introduces a few limitations:

1. **Lack of Flexibility**: Custom component definitions cannot use alternative property names for child components (e.g., `header`, `footer`, `avatar`, `leftAction`).
2. **Boilerplate and Code Duplication**: Component implementations (such as `TabsComponent` in Angular) are forced to manually normalize child properties nested inside objects or arrays because the binder only resolves top-level properties matching the hardcoded names.
3. **Inconsistency**: Dynamic child lists template are resolved to `{id, basePath}` objects but static child lists (which are just arrays of IDs) are returned as raw string arrays, requiring renderers to write custom fallback code.

---

## Proposed Design (Updated)

We propose making both binders fully schema-driven for child component resolution using a combination of Zod runtime metadata, Zod branded types for compile-time safety, recursive binder resolution, and clean JSON Schema reference generation:

### 1. Tag and Brand Core Schemas via a Custom Helper Function

We define custom decoupled metadata properties on Zod schema definitions (`_def` objects) to control runtime binding and schema reference generation separately:
- `a2uiType`: Declares the binder's runtime mapping behavior (e.g. `CHILD`, `STRUCTURAL`, `ACTION`, `DYNAMIC`).
- `refPath`: Declares the JSON Schema `$ref` pointer path in the generated client catalog.

Instead of globally mutating Zod's prototype class—which creates side-effects, risks library name collisions, hinders tree-shaking, and introduces temporal coupling—we define a clean, side-effect-free **Custom Helper Function** inside `renderers/web_core/src/v0_9/schema/common-types.ts` to attach this metadata to the schemas in a type-safe way:

```typescript
export interface A2uiTypeDef {
a2uiType?: 'Dynamic' | 'ComponentId' | 'ChildList' | 'Action';
refPath?: string;
}

/**
* Attaches A2UI metadata to a Zod schema's internal definition safely.
*/
export function withA2uiMetadata(
schema: T,
metadata: A2uiTypeDef
): T {
Object.assign(schema._def, metadata);
return schema;
}
```

This lets us brand and register schemas cleanly in a functional, declarative style:

```typescript
export const ComponentIdSchema = withA2uiMetadata(
z.string().brand<'ComponentId'>().describe('The unique identifier for a component.'),
{
a2uiType: 'ComponentId',
refPath: 'common_types.json#/$defs/ComponentId'
}
);

export const ChildListSchema = withA2uiMetadata(
z.union([
z.array(ComponentIdSchema).describe('A static list of child component IDs.'),
z.object({
componentId: ComponentIdSchema,
path: z.string().describe('The path to the list of component property objects in the data model.'),
}),
]).describe('A static list of child component IDs or a dynamic list template.'),
{
a2uiType: 'ChildList',
refPath: 'common_types.json#/$defs/ChildList'
}
);
```

Zod shallow-copies the `_def` object when wrapper methods (like `.optional()` or `.nullable()`) are called, so this metadata automatically survives and propagates down the schema wrappers without any global prototype side-effects.

---

### 2. Clean JSON Schema Reference Generation

Instead of encoding reference pointers inside `.describe("REF:...")` string hacks (which required a post-processing parsing step `processRefs`), we utilize `zod-to-json-schema`'s `override` callback inside `MessageProcessor.generateInlineCatalog` to inspect the `refPath` metadata directly:

```typescript
const zodToJsonSchemaOptions = {
target: 'jsonSchema2019-09' as const,
override: (def: any) => {
const a2uiDef = def as A2uiTypeDef;
if (a2uiDef.refPath) {
return {
$ref: a2uiDef.refPath,
description: (def as any).description, // Preserve clean description
};
}
return ignoreOverride; // Fallback to default converters
},
};
```

This generates correct, clean `$ref` schemas in the catalog JSON, allowing us to delete the `MessageProcessor.processRefs` post-processing step entirely.

---

### 3. Recursive Compile-Time Type Resolution

To avoid complex, hard-to-read chained ternaries in typescript types, we replace the flat type mapping helpers in the binders with recursive type resolvers split into named intermediate helpers.

For example, in `renderers/angular/v0_9/core/types.ts`:

```typescript
type UnwrappedDynamic = Exclude;

// 1. Recurse nested objects/arrays
type ResolveAngularPropNested = T extends (infer U)[]
? ResolveAngularProp[]
: T extends object
? {[K in keyof T]: ResolveAngularProp}
: T;

// 2. Resolve properties after stripping dynamic value schemas
type ResolveNonComponentProp =
UnwrappedDynamic extends never ? any : ResolveAngularPropNested>;

// 3. Resolve child component references
type ResolveNonNullAngularProp = [T] extends [ChildList]
? Child[]
: [T] extends [ComponentId]
? Child
: ResolveNonComponentProp;

// 4. Handle nullability and distribute unions
export type ResolveAngularProp = T extends null | undefined ? T : ResolveNonNullAngularProp;
```

This maps nested `ComponentId` properties (such as `Tabs.tabs[].child`) to `Child` (`{id, basePath}`) and resolves dynamic types to raw literals at compile time with strict type-safety. A similar split helper is implemented for `ResolveA2uiProp` in the generic binder.

---

### 4. Behavior Scraping in Generic Binder

Add a new behavior type `CHILD` to `BehaviorNode` in `generic-binder.ts`. Update `getFieldBehavior` to return behaviors using explicit `a2uiType` tag matching by casting Zod `_def` to `A2uiTypeDef`:

```typescript
const a2uiType = (current._def as A2uiTypeDef).a2uiType;
if (a2uiType) {
switch (a2uiType) {
case 'ComponentId':
return {type: 'CHILD'};
case 'ChildList':
return {type: 'STRUCTURAL'};
case 'Action':
return {type: 'ACTION'};
case 'Dynamic':
return {type: 'DYNAMIC'};
}
}
```

---

### 5. Pass Schema and Implement Recursive Resolution in Angular Binder

- Update `ComponentHostComponent` to pass the catalog `api.schema` to `ComponentBinder.bind(context, api.schema)`.
- Rewrite `ComponentBinder.bind` to recursively traverse and resolve properties using a recursive helper `resolveNested` (matching the Preact resolution in `GenericBinder`), replacing the hardcoded property name checks:

```typescript
private resolveNested(value: any, behavior: BehaviorNode, context: ComponentContext): Signal {
if (value === undefined || value === null) {
if (behavior.type === 'STRUCTURAL') return signal([]);
return signal(value);
}
switch (behavior.type) {
case 'CHILD': // Resolve to Child reference
case 'STRUCTURAL': // Resolve to Child[]
case 'DYNAMIC': // Resolve dynamic value path/call
case 'ARRAY': // Recurse array
case 'OBJECT': // Recurse object shape
...
}
}
```

---

### 6. Dart/Flutter (Other Renderers) Support

Since the inline catalog sent over the wire contains standard JSON Schema `$ref` properties pointing to `common_types.json#/$defs/ComponentId` and `ChildList`, non-TypeScript clients (such as Flutter/Dart) can easily identify child component elements at runtime by inspecting the schema, or map them automatically during code generation (using tools like `freezed` or `json_serializable`).

---

### 7. Resilience and Edge Cases

- **Missing Schemas**: Fallback to `z.object({})` in `ComponentBinder.bind` if a component's schema is not defined in the catalog.
- **Null Defaults**: Resolve null/undefined children lists to `[]` for `STRUCTURAL` behavior to ensure type safety.

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.