a2ui-project / a2ui-project/a2ui
[Proposal] Resolve child components based on schema and not on hardcoded 'child'/'children' keywords. Also improve REF: handling
- Vorherrschende Sprache
- TypeScript
- Sterne
- 16.4k
- Forks
- 1.3k
- Ø Merge
- 2 T. 13 Std.
- Gemergte PRs (30 T.)
- 134
Beschreibung
# 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.
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.