Summary
A has-one created by writing into its placeholder (parent fetched with the relation null) is reset to disconnected on the first render after a successful persist. The created row exists on the server, but the accessor shows an empty placeholder again, and the next edit + persist creates a second related row and reconnects the parent to it, detaching the first one.
Environment
Reproduction
Uses the shared has-one fixture (article-no-author has author: null):
const article = useEntity(entityDefs.Article, { by: { id: 'article-no-author' } }, e =>
e.id().title().author(a => a.id().name()),
)
// disconnected placeholder → write stages a nested create
article.author.name.setValue('Created Author')
await article.$persist()
// persist sends `author: { create: { name: 'Created Author' } }` and succeeds
article.author.$state // 'disconnected' (expected 'connected')
article.author.name.value // null (expected 'Created Author')
The same happens with <HasOne field={post.content}> + usePersist().persistAll().
Expected behavior
After a confirmed persist the relation stays connected to the created entity (its temp id rekeyed to the persisted id), exactly as it is for a has-one that was created on a parent that was never fetched.
Actual behavior
Tracing the has-one store writes for Article:article-no-author:author during the persist:
materializePlaceholderHasOne → connected to __temp_…
reconcileSentTransition → serverId = currentId = __temp_…, serverState: connected
replaceEntityId (via mapTempIdToPersistedId) → currentId = serverId = <persisted id>
HasOneHandle.advanceServerBaselineOnRefetch (from ensureEntry ← relatedId ← entity) → currentId: null, serverId: null, state: disconnected, serverState: disconnected
Step 4 runs on the next render. From then on the UI shows the placeholder, and the next write + persist issues a fresh create.
Suspected root cause
advanceServerBaselineOnRefetch decides that the parent was re-fetched with null when the relation is no longer locally dirty and hasEmbeddedDataChanged returns true:
// packages/bindx/src/store/SnapshotStore.ts:233-235
hasEmbeddedDataChanged(parentType, parentId, fieldName, currentData) {
const key = this.getRelationKey(parentType, parentId, fieldName)
return this.lastPropagatedData.get(key) !== currentData
}
The propagation slot is only written for a connected embedded object (ensureRelatedEntitySnapshot, HasOneHandle.ts:426 / :441). An embedded null is never recorded, so lastPropagatedData.get(key) is undefined and undefined !== null reads as "changed" forever.
Before the persist the check never runs, because the relation is creating (locally dirty). After the persist it is clean, and the parent snapshot still embeds the null from the initial fetch (the persist refreshes only entity.scalarData, BatchPersister.ts:1149). So the stale null is treated as a re-fetch and wipes the relation (HasOneHandle.ts:256).
Suggested fix
Either of these, both in the has-one path:
- Record the embedded reference as observed when the handle first reads it, including
null. For example, ensureEntry / advanceServerBaselineOnRefetch could call markEmbeddedDataPropagated for an embedded null before returning early, so only a genuinely new reference counts as a re-fetch.
- Or, when a has-one transition is confirmed in
reconcileConfirmedEntities, mark the parent's current embedded value for that field as propagated (or refresh the parent's embedded relation data to the persisted id), so the pre-persist snapshot can no longer look like fresh server data.
The first one seems the most local. Maintainers may prefer the second.
Workaround shipped downstream
We applied a temporary workaround in our project, marked TODO [BindX] (<this-issue-url>): <description>. Before rendering the has-one editor, the workaround marks the parent's embedded null as already propagated (store.markEmbeddedDataPropagated(parentType, parentId, field, null)) when the parent was fetched with the relation null. We will remove it once this issue is resolved.
Summary
A has-one created by writing into its placeholder (parent fetched with the relation
null) is reset todisconnectedon the first render after a successful persist. The created row exists on the server, but the accessor shows an empty placeholder again, and the next edit + persist creates a second related row and reconnects the parent to it, detaching the first one.Environment
@contember/bindx@0.1.51(version installed in the reporting project)contember/bindx@mainas ofa2cc02etests/react/relations/hasOne/create-after-null-load.test.tsxbug/has-one-create-reset-after-persist-when-loaded-nullReproduction
Uses the shared has-one fixture (
article-no-authorhasauthor: null):The same happens with
<HasOne field={post.content}>+usePersist().persistAll().Expected behavior
After a confirmed persist the relation stays
connectedto the created entity (its temp id rekeyed to the persisted id), exactly as it is for a has-one that was created on a parent that was never fetched.Actual behavior
Tracing the has-one store writes for
Article:article-no-author:authorduring the persist:materializePlaceholderHasOne→connectedto__temp_…reconcileSentTransition→serverId = currentId = __temp_…,serverState: connectedreplaceEntityId(viamapTempIdToPersistedId) →currentId = serverId = <persisted id>HasOneHandle.advanceServerBaselineOnRefetch(fromensureEntry←relatedId←entity) →currentId: null, serverId: null, state: disconnected, serverState: disconnectedStep 4 runs on the next render. From then on the UI shows the placeholder, and the next write + persist issues a fresh
create.Suspected root cause
advanceServerBaselineOnRefetchdecides that the parent was re-fetched withnullwhen the relation is no longer locally dirty andhasEmbeddedDataChangedreturns true:The propagation slot is only written for a connected embedded object (
ensureRelatedEntitySnapshot,HasOneHandle.ts:426/:441). An embeddednullis never recorded, solastPropagatedData.get(key)isundefinedandundefined !== nullreads as "changed" forever.Before the persist the check never runs, because the relation is
creating(locally dirty). After the persist it is clean, and the parent snapshot still embeds thenullfrom the initial fetch (the persist refreshes onlyentity.scalarData,BatchPersister.ts:1149). So the stalenullis treated as a re-fetch and wipes the relation (HasOneHandle.ts:256).Suggested fix
Either of these, both in the has-one path:
null. For example,ensureEntry/advanceServerBaselineOnRefetchcould callmarkEmbeddedDataPropagatedfor an embeddednullbefore returning early, so only a genuinely new reference counts as a re-fetch.reconcileConfirmedEntities, mark the parent's current embedded value for that field as propagated (or refresh the parent's embedded relation data to the persisted id), so the pre-persist snapshot can no longer look like fresh server data.The first one seems the most local. Maintainers may prefer the second.
Workaround shipped downstream
We applied a temporary workaround in our project, marked
TODO [BindX] (<this-issue-url>): <description>. Before rendering the has-one editor, the workaround marks the parent's embeddednullas already propagated (store.markEmbeddedDataPropagated(parentType, parentId, field, null)) when the parent was fetched with the relationnull. We will remove it once this issue is resolved.