Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/bindx/src/handles/PlaceholderHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
/**
* Creates a field handle for placeholder data.
* For has-many relations, returns an empty has-many-like handle with items/map/length.
* For has-one relations, returns a nested placeholder has-one handle so chains like
* `<HasOne field={parentPlaceholder.profile}>` 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
Expand All @@ -166,6 +169,14 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
return emptyHasMany
}

// For has-one relations, return a placeholder-of-placeholder has-one handle
if (this.schema?.isHasOne(this.targetType, fieldName)) {
const innerTargetType = this.schema.getRelationTarget(this.targetType, fieldName)
if (innerTargetType) {
return this.createPlaceholderHasOneFieldHandle(fieldName, innerTargetType)
}
}

const self = this

return {
Expand Down Expand Up @@ -259,6 +270,88 @@ export class PlaceholderHandle<TEntity extends object = object, TSelected = TEnt
}
}

/**
* Creates a nested placeholder has-one handle.
*
* Used when a placeholder entity (the outer placeholder) is asked for one of its
* has-one relations — there is no real parent row, so the inner relation is also
* a placeholder. The returned handle exposes the HasOne-shaped surface that
* `<HasOne>` 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<object> { 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<symbol> | 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<EventTypeMap['relation:connected']>): Unsubscribe { return noopUnsubscribe },
onDisconnect(_listener: EventListener<EventTypeMap['relation:disconnected']>): Unsubscribe { return noopUnsubscribe },
interceptConnect(_interceptor: Interceptor<EventTypeMap['relation:connecting']>): Unsubscribe { return noopUnsubscribe },
interceptDisconnect(_interceptor: Interceptor<EventTypeMap['relation:disconnecting']>): Unsubscribe { return noopUnsubscribe },
on<E extends AfterEventTypes>(_eventType: E, _listener: EventListener<EventTypeMap[E]>): Unsubscribe { return noopUnsubscribe },
intercept<E extends BeforeEventTypes>(_eventType: E, _interceptor: Interceptor<EventTypeMap[E]>): Unsubscribe { return noopUnsubscribe },
onPersisted(_listener: EventListener<EntityPersistedEvent>): Unsubscribe { return noopUnsubscribe },
interceptPersisting(_interceptor: Interceptor<EntityPersistingEvent>): Unsubscribe { return noopUnsubscribe },
subscribe(_callback: () => void): Unsubscribe { return noopUnsubscribe },
}

return createHandleProxy(handleLike, target => target.fields)
}

/**
* Type brand for EntityRef compatibility.
*/
Expand Down
161 changes: 161 additions & 0 deletions tests/react/jsx/HasOneNullRelation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Regression test for https://github.com/contember/bindx/issues/32
//
// `<HasOne field={...}>` 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<T>` (non-nullable), so callers
// don't guard — and crash on the first field access (`Cannot read properties
// of undefined (reading '<field>')`).
//
// Bug observed in NPI (`packages/admin/app/components/publications/seo-card.tsx`):
// outer `<HasOne field={product.seo}>` auto-creates the placeholder, then the
// inner `<HasOne field={seo.image}>` over the disconnected image relation
// gives `undefined` to the callback. The same shape reproduces here with
// `<HasOne field={article.author}>{author => <HasOne field={author.profile}>…`.

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<NestedSchema>({
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>('Article'),
Author: entityDef<Author>('Author'),
Profile: entityDef<Profile>('Profile'),
} as const

const mockData = {
Article: {
'article-1': {
id: 'article-1',
title: 'Article 1',
// Both levels disconnected — outer `author` is null, so the
// inner `<HasOne field={author.profile}>` 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 <div data-testid="loading">Loading…</div>
if (article.$isError || article.$isNotFound) return <div data-testid="error">Error</div>

return (
<div>
<HasOne field={article.author}>
{author => (
<HasOne field={author.profile}>
{profile => (
<div data-testid="profile-block">
<span data-testid="profile-bio">{profile.bio.inputProps.value ?? 'empty'}</span>
</div>
)}
</HasOne>
)}
</HasOne>
</div>
)
}

const { container } = render(
<BindxProvider adapter={adapter} schema={nestedSchema}>
<TestComponent />
</BindxProvider>,
)

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')
})
})