a2ui-project / a2ui-project/a2ui

[BUG]: GenericBinder misclassifies nested dynamic unions as STATIC

Abierto
#2,530 1 comentario 0 reacciones 1 asignado Reclamado por @andrewkolos Ver en GitHub
status: first-line-handled
Lenguaje dominante
TypeScript
Estrellas
16.4k
Forks
1.3k
Merge medio
2 d 13 h
PR fusionados (30 d)
134

Descripción

- [x] I have searched the existing issues to make sure this bug has not already been reported.

## Describe the Bug

### Background

`@a2ui/web_core`'s `GenericBinder` decides per property how to treat the incoming JSON value by scraping the component's Zod schema (`getFieldBehavior` in `renderers/web_core/src/v0_9/rendering/generic-binder.ts`). A property classified `DYNAMIC` gets the full data-binding treatment: `{path}` and `{call}` values are resolved through the `DataContext`, subscribed for updates, and a runtime setter is generated. A property classified `STATIC` is passed through untouched.

`getFieldBehavior` recognizes a dynamic property two ways:

- a `REF:` marker in the schema description (`#/$defs/DynamicString` and friends)
- a structural check that scans a `ZodUnion`'s options for the `DataBindingSchema` object shape
(an object with `path` and no `componentId`).

Both checks look exactly one level deep.

### The bug

A schema that composes a `Dynamic*` schema into a wider union defeats both checks. The basic catalog's own `DateTimeInput` does this for `min` and `max` (`renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts`):

```ts
'min': z
.union([DynamicStringSchema, z.string().date(), z.string().time(), z.string().datetime()])
.describe('The minimum allowed date/time in ISO 8601 format.')
.optional(),
```

~~The outer `.describe()` replaces the description, so the union carries no `REF:` marker.~~

> **Correction (per review on #2531):** `.describe()` is not what hides the marker. `z.union([...])` creates a new schema with no description of its own, so the outer union never had a `REF:` marker to replace. The marker stays on the nested `DynamicStringSchema`; `getFieldBehavior` simply does not read the descriptions of a union's options. Removing the `.describe()` would not change the outcome.

And `DynamicStringSchema` is itself a `ZodUnion`, not a `ZodObject`, so the structural scan never sees the `{path}` branch nested inside it. Both properties scrape as `STATIC`, while the sibling `value`, a bare `DynamicStringSchema`, scrapes as `DYNAMIC`.

The consequences, all silent:

- A bound `min` reaches the component implementation as the raw `{path: '...'}` object: never resolved, never subscribed. The React implementation's defensive check (`typeof props.min === 'string' ? props.min : undefined`) then discards the object, so the constraint is simply dropped.
- The type layer and the runtime disagree about setters. `GenerateSetters` sees the `DataBinding` branch in the union and declares `setMin`/`setMax` on the resolved props type, but the binder only generates runtime setters for `DYNAMIC` properties, so the typed method is `undefined` at runtime.

The trap is not specific to `DateTimeInput`: any catalog schema that wraps a `Dynamic*` schema inside a wider union (hand-written Zod catalogs can) gets the same misclassification.

## Steps to Reproduce

1. Scrape the shipped `DateTimeInput` schema:

```ts
import {scrapeSchemaBehavior} from '@a2ui/web_core/v0_9';
import {DateTimeInputApi} from '@a2ui/web_core/v0_9/basic_catalog';

const behavior = scrapeSchemaBehavior(DateTimeInputApi.schema);
console.log(behavior.shape.min); // {type: 'STATIC'} - wrong
console.log(behavior.shape.value); // {type: 'DYNAMIC'} - correct, same DynamicStringSchema
```

2. Or observe it end to end: render a surface with a `DateTimeInput` whose `min` is `{"path": "/limits/min"}` and any value at `/limits/min` in the data model. The rendered input has no minimum constraint, and updating `/limits/min` has no effect.

## Expected Behavior

`min` and `max` classify as `DYNAMIC` like their sibling `value`: a bound value is resolved, subscribed, and delivered to the component as a string, and the generated setters exist at runtime as the props type promises.

## Environment Details

- **OS**: macOS 15
- **Browser/Platform**: Node.js 22
- **SDK/Package Name & Version**: `@a2ui/web_core` 0.10.7 (reproduced against `main` @ `0dc4a30f`)
- **Protocol Version**: v0.9,v0.9.1
- **Agent Framework & LLM Model**: n/a

## Additional Context

**Suggested fix** - make the structural union check recurse: a union any of whose branches is itself dynamic (nested `Dynamic*` union, or a branch carrying a `Dynamic*`/`DataBinding` `REF:` description) is dynamic. The fix belongs in the scraper rather than in the `DateTimeInput` schema, because third-party catalogs composing `Dynamic*` schemas into wider unions hit the same misclassification.

Prior art on the `v1_0` branch, where this is already solved twice over:

- the v1.0 basic catalog flattened `min`/`max` to a bare `DynamicStringSchema` with the `REF:` marker preserved, removing the trigger
- #2353 rewrites the classifier so the union check reads each option's `REF:` definition name (`getRefDefName(o).startsWith('Dynamic')`), which classifies this exact shape as `DYNAMIC`

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.