Summary
<HasOne field={...}> over a nullable has-one relation whose target is also a placeholder (parent placeholder → child placeholder) fires its children callback with undefined instead of the placeholder entity ref. The typed contract claims EntityRef<T> (non-nullable), so callers don't guard — first field access crashes with Cannot read properties of undefined.
Environment
Reproduction
Schema: Article → Author → Profile, both relations nullable has-one.
Mock data: article-1 exists, its author is null (so the outer <HasOne> hands out a placeholder Author; the inner <HasOne> then runs over the placeholder author's still-null profile).
<HasOne field={article.author}>
{author => (
<HasOne field={author.profile}>
{profile => (
<span>{profile.bio.value ?? 'empty'}</span>
)}
</HasOne>
)}
</HasOne>
A single-level disconnected <HasOne> works fine (placeholder is returned, value === null). The crash is specific to nested has-one where the outer level is already a placeholder.
Expected behavior
useEntity returns a fully-formed placeholder accessor for the same disconnected hasOne (see tests/react/relations/hasOne/placeholder.test.tsx → "nullable has-one always returns accessor, never null"). <HasOne> JSX should behave the same — its children should always receive a defined ref so callers can read .value without runtime guards, since the TypeScript contract for HasOne does not permit undefined.
Actual behavior
TypeError: undefined is not an object (evaluating 'profile.bio')
at children (tests/react/jsx/HasOneNullRelation.test.tsx:136:44)
at HasOneImpl (packages/bindx-react/src/jsx/components/HasOne.tsx:36:17)
HasOneImpl runs const result = children(accessor.$entity) where accessor.$entity resolves to undefined for a placeholder-of-placeholder hasOne. The TS signature for the children prop is (ref: EntityRef<…>) => ReactNode, so consumers — including bindx-ui form components — never guard.
Suspected root cause
packages/bindx-react/src/jsx/components/HasOne.tsx calls children(accessor.$entity). useHasOne(field) returns an accessor whose $entity is built by the hasOne handle materialization in packages/bindx/src/handles/HasOneHandle.ts. For a placeholder parent whose target relation has never been touched, the handle short-circuits before producing the inner placeholder entity ref — so $entity reads as undefined. The recent fixes around fix/placeholder-hasone-materialization (commits edd8521, 1339efe) target the persist path; the read-time render path (HasOne JSX → useHasOne → $entity) still emits undefined when the nesting is creating → creating.
Suggested fix
Make useHasOne().$entity materialize the placeholder entity recursively on read for any hasOne, not just on persist. The single-level disconnected case already does this; extending the same fallback to nested placeholders should keep the existing single-level behavior intact and fix the nested case. Alternatively, narrow the typed signature of <HasOne>'s children to EntityRef<…> | undefined so consumers must guard — but that's a churn for every call site and disagrees with the existing single-level guarantee.
Workaround shipped downstream
We applied a temporary workaround in our project, marked TODO [BindX] (<this-issue-url>): …. The workaround drops the nested <HasOne field={seo.image}> for now and exposes only the outer-level fields (title / description / keywords) plus a placeholder card for the relation that crashes; we will restore the inner has-one editor once this issue is resolved.
Summary
<HasOne field={...}>over a nullable has-one relation whose target is also a placeholder (parent placeholder → child placeholder) fires itschildrencallback withundefinedinstead of the placeholder entity ref. The typed contract claimsEntityRef<T>(non-nullable), so callers don't guard — first field access crashes withCannot read properties of undefined.Environment
@contember/bindx@0.1.37(version installed in the reporting project)contember/bindx@mainas of79dd562tests/react/jsx/HasOneNullRelation.test.tsxbug/has-one-many-has-one-undefined-refReproduction
Schema:
Article → Author → Profile, both relations nullable has-one.Mock data:
article-1exists, itsauthorisnull(so the outer<HasOne>hands out a placeholder Author; the inner<HasOne>then runs over the placeholder author's still-nullprofile).A single-level disconnected
<HasOne>works fine (placeholder is returned,value === null). The crash is specific to nested has-one where the outer level is already a placeholder.Expected behavior
useEntityreturns a fully-formed placeholder accessor for the same disconnected hasOne (seetests/react/relations/hasOne/placeholder.test.tsx→ "nullable has-one always returns accessor, never null").<HasOne>JSX should behave the same — its children should always receive a defined ref so callers can read.valuewithout runtime guards, since the TypeScript contract forHasOnedoes not permitundefined.Actual behavior
HasOneImplrunsconst result = children(accessor.$entity)whereaccessor.$entityresolves toundefinedfor a placeholder-of-placeholder hasOne. The TS signature for the children prop is(ref: EntityRef<…>) => ReactNode, so consumers — including bindx-ui form components — never guard.Suspected root cause
packages/bindx-react/src/jsx/components/HasOne.tsxcallschildren(accessor.$entity).useHasOne(field)returns an accessor whose$entityis built by the hasOne handle materialization inpackages/bindx/src/handles/HasOneHandle.ts. For a placeholder parent whose target relation has never been touched, the handle short-circuits before producing the inner placeholder entity ref — so$entityreads asundefined. The recent fixes aroundfix/placeholder-hasone-materialization(commitsedd8521,1339efe) target the persist path; the read-time render path (HasOneJSX →useHasOne→$entity) still emitsundefinedwhen the nesting iscreating → creating.Suggested fix
Make
useHasOne().$entitymaterialize the placeholder entity recursively on read for any hasOne, not just on persist. The single-level disconnected case already does this; extending the same fallback to nested placeholders should keep the existing single-level behavior intact and fix the nested case. Alternatively, narrow the typed signature of<HasOne>'s children toEntityRef<…> | undefinedso consumers must guard — but that's a churn for every call site and disagrees with the existing single-level guarantee.Workaround shipped downstream
We applied a temporary workaround in our project, marked
TODO [BindX] (<this-issue-url>): …. The workaround drops the nested<HasOne field={seo.image}>for now and exposes only the outer-level fields (title / description / keywords) plus a placeholder card for the relation that crashes; we will restore the inner has-one editor once this issue is resolved.