Skip to content

HasOne JSX over nested nullable has-one passes undefined to children when both levels are placeholders #32

@vparys

Description

@vparys

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions