Skip to content

fix(web_core): make GenericBinder schema classification zod-version-agnostic and fail loudly on unknown internals - #2753

Open
lukegalea wants to merge 1 commit into
a2ui-project:mainfrom
lukegalea:fix/generic-binder-version-agnostic-classification
Open

lukegalea wants to merge 1 commit into
a2ui-project:mainfrom
lukegalea:fix/generic-binder-version-agnostic-classification

Conversation

@lukegalea

Copy link
Copy Markdown

fix(web_core): make GenericBinder schema classification zod-version-agnostic and fail loudly on unknown internals

Description

Sibling of #2530: same silent symptom (Dynamic/Action/ChildList properties scraping as STATIC, dropping every binding with zero errors), different root cause. #2531 fixes the nested-union case for schemas built with zod 3; this PR fixes the case where the catalog itself was built with a different zod major than the one @a2ui/web_core was compiled against — and turns the remaining silent fallback into a loud, actionable error.

The failure mode (dual-zod)

Catalogs are plain Zod schemas supplied by the host application. When dependency resolution leaves the host with a single hoisted zod copy at v4, the catalog's schemas are zod-4 nodes, but getFieldBehavior in renderers/web_core/src/v0_9/rendering/generic-binder.ts reads zod 3 internals exclusively:

  • zod 3 discriminates nodes with _def.typeName ('ZodUnion', 'ZodObject', …) and keeps object shapes behind _def.shape().
  • zod 4 discriminates nodes with def.type ('union', 'object', …), stores object shapes directly at def.shape, array elements at def.element, and keeps descriptions in a metadata registry read through the .description getter.

Under such a host, every internal check misses: _def.typeName is undefined, _def.shape is an object (not a factory), and _def.description is undefined even for described schemas. Every property of every component — first-party or third-party — silently scrapes as {type: 'STATIC'}. Repro essence:

import {scrapeSchemaBehavior} from '@a2ui/web_core/v0_9';
import {z as z4} from 'zod/v4'; // genuine zod 4 internals, shipped with zod 3.25+

// a catalog schema written the way a zod-4 host writes it
const schema = z4.object({
  value: z4.union([z4.string(), z4.object({path: z4.string()})])
    .describe('REF:common_types.json#/$defs/DynamicString|...'),
  min:  z4.union([z4.string(), z4.object({path: z4.string()})]).optional(),
});

scrapeSchemaBehavior(schema as any);
// before: {type: 'OBJECT', shape: {value: {type: 'STATIC'}, min: {type: 'STATIC'}}}  ← all bindings silently dead
// after:  {type: 'OBJECT', shape: {value: {type: 'DYNAMIC'}, min: {type: 'DYNAMIC'}}}

End to end, a {"path": "/model/value"} prop is delivered to the component implementation as the raw {path: ...} object instead of the bound value, generated setters don't exist at runtime, and nothing logs or throws. Third-party custom catalogs (hand-written zod 4 schemas, which also carry no REF: markers) are hit the hardest — there is no way for the catalog author to work around it from their side.

What this PR changes

  1. Version-agnostic introspection. A small set of helpers (internalsOf, kindOf, objectShapeOf, unionOptionsOf, descriptionOf) accepts both internal layouts: they read _def.typeName or def.type for the node kind, unwrap optionals/nullables/defaults in either vocabulary, read object shapes via _def.shape() or def.shape, read union options from either def.options, and read descriptions through the .description getter (which both majors implement — zod 4's reads the metadata registry). Classification logic itself is unchanged: same checks, same order, same precedence, still no instanceof (which would break with duplicate zod copies).
  2. Loud failure instead of silent STATIC. If a schema node exposes internals that are neither zod 3 (_def.typeName) nor zod 4 (def.type), scraping now throws a descriptive error naming the property path (e.g. (root).outer.inner) and the likely dual-zod cause, instead of returning {type: 'STATIC'} and silently dropping bindings. Known-but-unclassified nodes (strings, effects, records, …) keep returning STATIC exactly as before — the throw only fires for nodes that carry no recognizable zod internals at all.
  3. Sibling hardening: childRefKindOf in schema/common-types.ts (used by the scraper for the STRUCTURAL check) now optional-chains the _def read, so a _def-less node flows into the descriptive error above instead of crashing with a bare TypeError inside the helper.

Relationship to #2530/#2531 and the v1_0 rework (#2536)

Test evidence

Tests live alongside the scraper in generic-binder.test.ts and use the zod/v4 subpath of zod 3.25 — i.e. genuine zod 4 nodes, not hand-built def shapes (installing two zod majors side by side in one package isn't practical, but zod/v4 gives us the real v4 classic internals):

  • zod-4-built dynamic unions classify DYNAMIC (bare, optional-wrapped, and as array elements); plain zod-4 leaves stay STATIC
  • zod-4-built action and child-list unions classify ACTION/STRUCTURAL
  • zod-3-built schemas (via CommonSchemas) are guarded against regression
  • unrecognizable nodes (_def/def with neither discriminator) throw the descriptive error, with the offending property path in the message
# renderers/web_core (yarn test → wireit: tsc -b + node --test dist/**/*.test.js)
# tests 535
# pass 535
# fail 0

format:check (Prettier) and lint (eslint: 0 errors) are clean.

Additional context

Verified against the built dist/ output that a zod-4-built catalog object now scrapes {value: DYNAMIC, min: DYNAMIC, onTap: OBJECT} where the previous code returned STATIC for everything. The new error path was also verified to name the root property ((root)) when the unrecognizable node is the schema passed to scrapeSchemaBehavior itself.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces version-agnostic schema introspection to support dual-zod setups (Zod v3 and Zod v4) in the GenericBinder. It refactors getFieldBehavior to dynamically extract Zod internals, adds descriptive error handling for unrecognized schema nodes, and updates childRefKindOf to safely handle nodes without _def. Comprehensive tests are also added to verify behavior under both Zod versions. The review feedback suggests using optional chaining on current when retrieving the constructor name to prevent potential runtime TypeErrors if current is null or undefined.

'to build this component catalog, and GenericBinder would silently scrape every ' +
'property as STATIC, dropping all data bindings. Align the zod major used by the ' +
`catalog with the one @a2ui/web_core resolves. Received node "${
current.constructor?.name ?? typeof current

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If current is null or undefined, accessing current.constructor will throw a TypeError (e.g., Cannot read properties of null). This will crash the application with a generic error instead of throwing the descriptive error intended here. Using optional chaining current?.constructor?.name ensures the descriptive error is safely thrown.

Suggested change
current.constructor?.name ?? typeof current
current?.constructor?.name ?? typeof current
References
  1. In TypeScript, when handling partial objects that may not fully implement an interface, defensively default their properties (e.g., using nullish coalescing or logical OR) to prevent runtime TypeErrors.

@github-actions github-actions Bot added the status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs label Sep 23, 2026
@nan-yu

nan-yu commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Thank you for this fix, @lukegalea! Introspecting both Zod 3 and Zod 4 layouts cleanly solves the silent binding-drop issue for host apps on Zod 4, and failing loudly with the exact property path makes diagnostics much clearer.

We have updated web_core to support v1.0 and moved the implementation from renders/web_core to typescript/web_core. Would you mind resolving the conflicts, and porting the fix to the new location?

@nan-yu nan-yu added status: waiting-for-author-response and removed status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs labels Sep 25, 2026
@lukegalea
lukegalea force-pushed the fix/generic-binder-version-agnostic-classification branch from c8ef459 to 67a9944 Compare October 2, 2026 02:54
lukegalea added a commit to lukegalea/a2ui that referenced this pull request Oct 2, 2026
…il loudly on unknown internals

GenericBinder's schema scraper read zod 3 internals exclusively
(_def.typeName, _def.shape(), _def.type for array elements). When a host
application builds its catalog schemas with zod 4, every node goes
unrecognized and scrapes as STATIC, silently dropping all data bindings,
actions, and child lists with no error.

- Read node kinds from either layout (zod 3 `_def.typeName`, zod 4
  `def.type`), normalized to the zod 3 names the scraper already compares.
- Read object shapes (`_def.shape()` or plain `def.shape`), array elements
  (`_def.type` or `def.element`), and unwrap zod 4 `pipe` nodes via `def.in`.
- Share one wrapper-unwrapping step between unwrapZodSchema and
  getRefDefName instead of two copies of the chain.
- Throw an error naming the property path (e.g. "(root).outer.items[]") and
  the likely dual-zod cause when a node has neither layout, instead of the
  silent STATIC fallback.

Tests use the zod/v4 subpath of zod 3.25 (genuine zod 4 internals).

Ported from renderers/web_core to typescript/web_core for a2ui-project#2753.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lukegalea added a commit to lukegalea/a2ui that referenced this pull request Oct 2, 2026
…il loudly on unknown internals

GenericBinder's schema scraper read zod 3 internals exclusively
(_def.typeName, _def.shape(), _def.type for array elements). When a host
application builds its catalog schemas with zod 4, every node goes
unrecognized and scrapes as STATIC, silently dropping all data bindings,
actions, and child lists with no error.

- Read node kinds from either layout (zod 3 `_def.typeName`, zod 4
  `def.type`), normalized to the zod 3 names the scraper already compares.
- Read object shapes (`_def.shape()` or plain `def.shape`), array elements
  (`_def.type` or `def.element`), and unwrap zod 4 `pipe` nodes via `def.in`.
- Share one wrapper-unwrapping step between unwrapZodSchema and
  getRefDefName instead of two copies of the chain.
- Throw an error naming the property path (e.g. "(root).outer.items[]") and
  the likely dual-zod cause when a node has neither layout, instead of the
  silent STATIC fallback.

Tests use the zod/v4 subpath of zod 3.25 (genuine zod 4 internals).

Ported from renderers/web_core to typescript/web_core for a2ui-project#2753.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lukegalea
lukegalea force-pushed the fix/generic-binder-version-agnostic-classification branch from 67a9944 to d284de7 Compare October 2, 2026 02:55
…il loudly on unknown internals

GenericBinder's schema scraper read zod 3 internals exclusively
(_def.typeName, _def.shape(), _def.type for array elements). When a host
application builds its catalog schemas with zod 4, every node goes
unrecognized and scrapes as STATIC, silently dropping all data bindings,
actions, and child lists with no error.

- Read node kinds from either layout (zod 3 `_def.typeName`, zod 4
  `def.type`), normalized to the zod 3 names the scraper already compares.
- Read object shapes (`_def.shape()` or plain `def.shape`), array elements
  (`_def.type` or `def.element`), and unwrap zod 4 `pipe` nodes via `def.in`.
- Share one wrapper-unwrapping step between unwrapZodSchema and
  getRefDefName instead of two copies of the chain.
- Throw an error naming the property path (e.g. "(root).outer.items[]") and
  the likely dual-zod cause when a node has neither layout, instead of the
  silent STATIC fallback.

Tests use the zod/v4 subpath of zod 3.25 (genuine zod 4 internals).

Ported from renderers/web_core to typescript/web_core for a2ui-project#2753.
@lukegalea
lukegalea force-pushed the fix/generic-binder-version-agnostic-classification branch from d284de7 to b3107e6 Compare October 2, 2026 02:57
@lukegalea

Copy link
Copy Markdown
Author

Thanks @nan-yu. I've ported the fix to typescript/web_core/src/resolution/generic-binder.ts and rebased it onto current main (f8b5879, which includes #2919, #2939 and #2887). The branch is now a single commit on top of main and merges cleanly.

main had restructured the resolution code, so I rewrote the fix to fit it rather than carry over the old diff:

  • Small helpers read node kinds from either the zod 3 layout (_def.typeName) or the zod 4 layout (def.type). They also read object shapes, array elements and zod 4 pipe inputs from either layout.
  • unwrapZodSchema and getRefDefName now share one unwrap step.
  • A schema node that matches neither layout throws an error naming the property path, e.g. "(root).outer.items[]". It used to fall back to STATIC silently.
  • The childRefKindOf guard from the original PR is gone, because main already has it.

The tests use the zod/v4 subpath of zod 3.25 to cover zod-4-built objects, wrappers, arrays, checkables and the action, dynamic and child-list unions. There's also a zod 3 regression case and a test for the error. Three of the new tests fail on main without the fix. Since #2859 adds accessibility to every root object, the new tests leave that entry out of their comparisons. I also added a CHANGELOG line under Unreleased.

In typescript/web_core, yarn build, yarn test (764 passing) and yarn lint (no errors) all pass. I didn't run the Lit or React browser suites locally, so I'm relying on CI for those.

@github-actions github-actions Bot added status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs and removed status: waiting-for-author-response labels Oct 2, 2026
@github-actions github-actions Bot removed the status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs label Oct 2, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants