a2ui-project / a2ui-project/a2ui
[BUG]: GenericBinder misclassifies nested dynamic unions as STATIC
- 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
Evaluación
Este issue todavía no se ha evaluado.