From 805a3ab6877292b3ec9be0eb90c387050c0b4a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Thu, 14 May 2026 13:44:42 +0200 Subject: [PATCH 1/2] test: reproduce nested placeholder hasOne handing undefined to children --- tests/react/jsx/HasOneNullRelation.test.tsx | 161 ++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/react/jsx/HasOneNullRelation.test.tsx diff --git a/tests/react/jsx/HasOneNullRelation.test.tsx b/tests/react/jsx/HasOneNullRelation.test.tsx new file mode 100644 index 0000000..b268c22 --- /dev/null +++ b/tests/react/jsx/HasOneNullRelation.test.tsx @@ -0,0 +1,161 @@ +// Regression test for https://github.com/contember/bindx/issues/32 +// +// `` over a nullable many/one-has-one relation that is +// currently null at runtime fires its children callback with `undefined` +// instead of the placeholder accessor `useEntity` returns for the same +// field. The typed contract claims `EntityRef` (non-nullable), so callers +// don't guard — and crash on the first field access (`Cannot read properties +// of undefined (reading '')`). +// +// Bug observed in NPI (`packages/admin/app/components/publications/seo-card.tsx`): +// outer `` auto-creates the placeholder, then the +// inner `` over the disconnected image relation +// gives `undefined` to the callback. The same shape reproduces here with +// `{author => …`. + +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + defineSchema, + entityDef, + HasOne, + hasOne, + MockAdapter, + scalar, + useEntity, +} from '@contember/bindx-react' + +afterEach(() => { + cleanup() +}) + +interface Profile { + id: string + bio: string | null + avatar: string | null +} +interface Author { + id: string + name: string + email: string | null + profile: Profile | null +} +interface Article { + id: string + title: string + author: Author | null +} +interface NestedSchema { + Article: Article + Author: Author + Profile: Profile +} + +const nestedSchema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + author: hasOne('Author', { nullable: true }), + }, + }, + Author: { + fields: { + id: scalar(), + name: scalar(), + email: scalar(), + profile: hasOne('Profile', { nullable: true }), + }, + }, + Profile: { + fields: { + id: scalar(), + bio: scalar(), + avatar: scalar(), + }, + }, + }, +}) + +const schema = { + Article: entityDef
('Article'), + Author: entityDef('Author'), + Profile: entityDef('Profile'), +} as const + +const mockData = { + Article: { + 'article-1': { + id: 'article-1', + title: 'Article 1', + // Both levels disconnected — outer `author` is null, so the + // inner `` runs on a placeholder + // author. This mirrors the NPI seo-card scenario where the + // product has no SEO meta row yet, the outer HasOne hands out + // a placeholder, and the inner one over the still-empty image + // relation crashes. + author: null, + }, + }, + Author: {}, + Profile: {}, +} + +function getByTestId(container: Element, testId: string): Element { + const el = container.querySelector(`[data-testid="${testId}"]`) + if (!el) throw new Error(`Element with data-testid="${testId}" not found`) + return el +} + +function queryByTestId(container: Element, testId: string): Element | null { + return container.querySelector(`[data-testid="${testId}"]`) +} + +describe('HasOne JSX — nested nullable has-one with no connected row', () => { + test('inner children callback receives a placeholder ref (not undefined) so field access does not crash', async () => { + const adapter = new MockAdapter(mockData, { delay: 0 }) + + function TestComponent(): React.ReactElement { + const article = useEntity(schema.Article, { by: { id: 'article-1' } }, a => + a.id().title().author(au => au.id().name().email().profile(p => p.id().bio()))) + + if (article.$isLoading) return
Loading…
+ if (article.$isError || article.$isNotFound) return
Error
+ + return ( +
+ + {author => ( + + {profile => ( +
+ {profile.bio.inputProps.value ?? 'empty'} +
+ )} +
+ )} +
+
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'loading')).toBeNull() + }) + + // Inner HasOne should still render — placeholder accessor returns + // `null` field values, not throw `Cannot read properties of undefined`. + expect(getByTestId(container, 'profile-block')).not.toBeNull() + expect(getByTestId(container, 'profile-bio').textContent).toBe('empty') + }) +}) From f659522f0c17168c5be136bc9d7ba96e09318181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Thu, 14 May 2026 13:44:50 +0200 Subject: [PATCH 2/2] fix: materialize nested placeholder hasOne so children get a defined ref PlaceholderHandle only handled has-many in its field-access proxy, so a has-one relation accessed on a placeholder entity fell through to the scalar fallback and exposed no $entity. then called children(undefined), crashing on the first field access. Adds an explicit has-one branch that returns a nested-placeholder has-one handle with $entity wired to an inner PlaceholderHandle, plus field-access proxying so chains like placeholder.profile.bio resolve to the inner placeholder's field handle instead of undefined. Mutations on the nested placeholder ($create/$connect) throw with a clear message, since the outer placeholder has no backing row to attach to. Fixes #32 --- .../bindx/src/handles/PlaceholderHandle.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/bindx/src/handles/PlaceholderHandle.ts b/packages/bindx/src/handles/PlaceholderHandle.ts index 368e5bb..41937c8 100644 --- a/packages/bindx/src/handles/PlaceholderHandle.ts +++ b/packages/bindx/src/handles/PlaceholderHandle.ts @@ -149,6 +149,9 @@ export class PlaceholderHandle` materialize an inner placeholder + * rather than handing `undefined` to the children callback. */ private createPlaceholderFieldHandle(fieldName: string): unknown { // For has-many relations, return an empty has-many-like handle @@ -166,6 +169,14 @@ export class PlaceholderHandle` JSX relies on (FIELD_REF_META, $entity, $state) plus field-access + * proxying so chains like `parentPlaceholder.profile.bio.value` resolve to `null` + * instead of throwing. + * + * Mutations (`$connect`, `$create`) are not supported on nested placeholders — + * materialize the outer placeholder first (e.g. by writing to a scalar field or + * connecting the outer relation). + */ + private createPlaceholderHasOneFieldHandle(fieldName: string, innerTargetType: string): unknown { + const self = this + const nestedPlaceholderRaw = PlaceholderHandle.createRaw( + this.targetType, + this.placeholderId, + fieldName, + innerTargetType, + this.store, + this.dispatcher, + this.schema, + this.__brands, + ) + const nestedPlaceholderProxy = PlaceholderHandle.wrapProxy(nestedPlaceholderRaw) + const fieldRefMeta: FieldRefMeta = { + entityType: self.targetType, + entityId: self.placeholderId, + path: [fieldName], + fieldName, + isArray: false, + isRelation: true, + targetType: innerTargetType, + } + const noopUnsubscribe: Unsubscribe = () => {} + + const handleLike = { + get [FIELD_REF_META](): FieldRefMeta { return fieldRefMeta }, + get id(): string { return nestedPlaceholderRaw.id }, + get state(): 'disconnected' { return 'disconnected' }, + get isConnected(): boolean { return false }, + get isNew(): boolean { return true }, + get isPersisting(): boolean { return false }, + get isDirty(): boolean { return false }, + get persistedId(): null { return null }, + get data(): null { return null }, + get entity(): EntityAccessor { return nestedPlaceholderProxy }, + get fields() { return nestedPlaceholderRaw.fields }, + get errors(): readonly FieldError[] { return [] }, + get hasError(): boolean { return false }, + get __entityName(): string { return innerTargetType }, + get __entityType(): unknown { return undefined }, + get __brands(): Set | undefined { return self.__brands }, + create(): string { + throw new Error(`Cannot $create on nested placeholder has-one "${fieldName}" — materialize the outer placeholder first`) + }, + connect(): void { + throw new Error(`Cannot $connect on nested placeholder has-one "${fieldName}" — materialize the outer placeholder first`) + }, + disconnect(): void {}, + delete(): void {}, + remove(): void {}, + reset(): void {}, + addError(_error: ErrorInput): void {}, + clearErrors(): void {}, + clearAllErrors(): void {}, + onConnect(_listener: EventListener): Unsubscribe { return noopUnsubscribe }, + onDisconnect(_listener: EventListener): Unsubscribe { return noopUnsubscribe }, + interceptConnect(_interceptor: Interceptor): Unsubscribe { return noopUnsubscribe }, + interceptDisconnect(_interceptor: Interceptor): Unsubscribe { return noopUnsubscribe }, + on(_eventType: E, _listener: EventListener): Unsubscribe { return noopUnsubscribe }, + intercept(_eventType: E, _interceptor: Interceptor): Unsubscribe { return noopUnsubscribe }, + onPersisted(_listener: EventListener): Unsubscribe { return noopUnsubscribe }, + interceptPersisting(_interceptor: Interceptor): Unsubscribe { return noopUnsubscribe }, + subscribe(_callback: () => void): Unsubscribe { return noopUnsubscribe }, + } + + return createHandleProxy(handleLike, target => target.fields) + } + /** * Type brand for EntityRef compatibility. */