Skip to content

Has-one created through its placeholder is reset to disconnected after persist when the parent was fetched with it null #112

Description

@MalaRuze

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:

  1. materializePlaceholderHasOne → connected to __temp_…
  2. reconcileSentTransition → serverId = currentId = __temp_…, serverState: connected
  3. replaceEntityId (via mapTempIdToPersistedId) → currentId = serverId = <persisted id>
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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