Repository navigation
fix(web_core): make GenericBinder schema classification zod-version-agnostic and fail loudly on unknown internals - #2753
Conversation
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| current.constructor?.name ?? typeof current | |
| current?.constructor?.name ?? typeof current |
References
- 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.
|
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 |
c8ef459 to
67a9944
Compare
…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>
…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>
67a9944 to
d284de7
Compare
…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.
d284de7 to
b3107e6
Compare
|
Thanks @nan-yu. I've ported the fix to
The tests use the In |
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/ChildListproperties scraping asSTATIC, 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_corewas 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
zodcopy at v4, the catalog's schemas are zod-4 nodes, butgetFieldBehaviorinrenderers/web_core/src/v0_9/rendering/generic-binder.tsreads zod 3 internals exclusively:_def.typeName('ZodUnion','ZodObject', …) and keeps object shapes behind_def.shape().def.type('union','object', …), stores object shapes directly atdef.shape, array elements atdef.element, and keeps descriptions in a metadata registry read through the.descriptiongetter.Under such a host, every internal check misses:
_def.typeNameisundefined,_def.shapeis an object (not a factory), and_def.descriptionisundefinedeven for described schemas. Every property of every component — first-party or third-party — silently scrapes as{type: 'STATIC'}. Repro essence: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 noREF:markers) are hit the hardest — there is no way for the catalog author to work around it from their side.What this PR changes
internalsOf,kindOf,objectShapeOf,unionOptionsOf,descriptionOf) accepts both internal layouts: they read_def.typeNameordef.typefor the node kind, unwrap optionals/nullables/defaults in either vocabulary, read object shapes via_def.shape()ordef.shape, read union options from eitherdef.options, and read descriptions through the.descriptiongetter (which both majors implement — zod 4's reads the metadata registry). Classification logic itself is unchanged: same checks, same order, same precedence, still noinstanceof(which would break with duplicate zod copies)._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 returningSTATICexactly as before — the throw only fires for nodes that carry no recognizable zod internals at all.childRefKindOfinschema/common-types.ts(used by the scraper for theSTRUCTURALcheck) now optional-chains the_defread, so a_def-less node flows into the descriptive error above instead of crashing with a bareTypeErrorinside the helper.Relationship to #2530/#2531 and the v1_0 rework (#2536)
main; happy to rebase after (or on) fix(web_core): classify dynamic unions nested inside wider unions as DYNAMIC #2531 lands.REF:description markers (getRefDefName(...).startsWith('Dynamic')). A zod-4-built third-party catalog written with plain zod schemas carries noREF:markers at all, so marker-based classification alone does not cover the dual-zod case; the structural checks hardened here remain necessary.Test evidence
Tests live alongside the scraper in
generic-binder.test.tsand use thezod/v4subpath 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, butzod/v4gives us the real v4 classic internals):DYNAMIC(bare, optional-wrapped, and as array elements); plain zod-4 leaves staySTATICACTION/STRUCTURALCommonSchemas) are guarded against regression_def/defwith neither discriminator) throw the descriptive error, with the offending property path in the messageformat:check(Prettier) andlint(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 returnedSTATICfor everything. The new error path was also verified to name the root property ((root)) when the unrecognizable node is the schema passed toscrapeSchemaBehavioritself.