Skip to content

Nested create keeps its temp ID after a successful persist and is then updated by __temp_ id #70

Description

@MalaRuze

Summary

When several sibling create operations sit in one hasMany mutation and carry different nested relation shapes, the mutation's node selection is built from only the last sibling's nested shape, so the response cannot be content-matched back to the other siblings. Those nested creates keep their __temp_… IDs, yet commitUnresolvedNestedEntities still marks them existsOnServer = true — so the next edit is emitted as an update keyed by a temp ID, which the API rejects (Expected type "UUID", found "__temp_…"). The store is stuck in an unsaveable state until a full reload.

Environment

Reproduction

Schema: Page → blocks (hasMany) → button (hasOne) → link (hasOne). Two sibling blocks are created in one persist; their button creates carry different fields (modalTitle on one, a nested link on the other) — exactly what "duplicate this section" produces in a page builder, where unset fields are simply absent from create data.

// sibling 1 — button with a modal title, no link
const blockA = store.createEntity('Block', { order: 1, type: 'button' })
const buttonA = store.createEntity('Button', { label: 'Kontaktujte nás', modalTitle: 'Kontakt' })
// sibling 2 — plain link button, no modalTitle
const blockB = store.createEntity('Block', { order: 2, type: 'button' })
const buttonB = store.createEntity('Button', { label: 'Napište nám' })
const link = store.createEntity('Link', { type: 'external', externalTarget: 'mailto:info@example.com' })
// …relations wired, both blocks added to page-1.blocks…

await persister.persistAll()                     // ok: true

store.getPersistedId('Button', buttonA)          // → null  (expected: a server UUID)
store.existsOnServer('Button', buttonA)          // → true

store.updateEntityFields('Button', buttonA, { label: 'Napište nám hned' })
await persister.persistAll()
// adapter receives: persist('Button', '__temp_5845872f-…', { label: 'Napište nám hned' })

The mock adapter in the test echoes exactly the fields the mutation node selection requested — the contract a real Contember API honours.

Expected behavior

  1. Every nested create in a successful mutation is reconciled to its server ID, regardless of how its siblings' nested payloads are shaped (the existing content matching: reversed server response order maps IDs correctly test in tests/nestedHasManyCreate.test.ts establishes that reconciliation is meant to be robust).
  2. Failing that, an entity whose temp ID could not be resolved must never be usable as the target of an update. Emitting persist(entityType, '__temp_…', …) cannot succeed against any backend — the store should keep the entity uncommitted (or invalidate it so a refetch replaces it) rather than commit it into a permanently unsaveable state.

Actual behavior

  • store.getPersistedId('Button', buttonA) returns null after a fully successful persist.

  • store.existsOnServer('Button', buttonA) returns true.

  • The next persist calls adapter.persist('Button', '__temp_5845872f-bba1-456c-b894-a8caa95fb53f', { label: … }). Against a real project this is updateButton(by: {id: "__temp_…"}) and the API answers HTTP 400:

    Variable "$by" got invalid value "__temp_5845872f-bba1-456c-b894-a8caa95fb53f";
    Expected type "UUID". Not a valid UUID
    

    Every subsequent save of the whole page fails the same way until the page is reloaded.

Suspected root cause

Two separate defects compound.

1. The node selection drops nested relation shapes of all but the last sibling. packages/bindx-client/src/graphql/mutationFragments.tsbuildSelectionFromOps unions scalar fields across sibling ops but keeps nested relations in a Map keyed by field name:

for (const [key, value] of Object.entries(innerData)) {
    if (value === null || value === undefined) continue
    if (typeof value === 'object') {
        nestedFields.set(key, value as Record<string, unknown>)   // last sibling wins
    } else {
        scalarFields.add(key)
    }
}

With sibling A's button.create = { label, modalTitle } and sibling B's button.create = { label, link: { create } }, only B's shape reaches buildSelectionFromCreateOrUpdate, so the emitted selection is button { id label link { id type externalTarget } } — no modalTitle.

2. Content matching then fails, and the failure is committed as success. packages/bindx/src/persistence/BatchPersister.tsisCreateDataMatchingNode (line ~1135) compares every non-null value of the create payload against the node:

if (typeof value !== 'object') {
    if (nodeItem[key] !== value) return false
}

modalTitle: 'Kontakt' vs. undefined → no match, so extractNestedResultsFromNode (line ~1102) skips sibling A entirely (if (idx < 0) continue) — and with it every nested create below A. commitUnresolvedNestedEntities (line ~986) then walks the collector's nested-entity map and does:

this.dispatcher.dispatch(commitEntity(entityType, tempId))
this.store.commitAllRelations(entityType, tempId)
this.store.setExistsOnServer(entityType, tempId, true)

without a paired mapTempIdToPersistedId, so the entity is flagged as server-resident while still keyed by its temp ID.

Suggested fix

  • In buildSelectionFromOps, merge nested relation shapes across sibling ops instead of overwriting them (deep-union the selections, or collect a list of nested payloads per field and union the selections they produce). This makes the response self-sufficient for content matching.
  • In commitUnresolvedNestedEntities, do not set existsOnServer for an entity whose temp ID was not mapped — or, if committing is required to keep the tree consistent, record the entity as needing a refetch and make the mutation collector refuse to emit an update for a __temp_ key (fail loudly at collection time rather than at the API). Emitting a mutation keyed by a temp ID is never correct.

We are not certain which of the two you want as the primary fix; the second one alone would already turn a silent, unrecoverable state into something a downstream app can detect.

Workaround shipped downstream

We applied a temporary workaround in our project, marked TODO [BindX] (<this-issue-url>): …. The workaround force-refetches the page-builder editor subtree after any persist that included a section duplication, so the store re-reads real UUIDs instead of keeping unresolved temp IDs; we will remove it 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