Skip to content

data-fetching: empty/unresolved name silently binds to a shared doctype/ cache slot in docStore.getDoc #792

Description

@mehmehsloth

Summary

In the src/data-fetching layer, docStore.getDoc() (and therefore useDoc / consumers like CRM's useDocument) accepts an empty or not-yet-resolved name and silently mints/binds a real, writable cache slot for the key `${doctype}/`. Because an empty name is a valid-looking distinct key rather than an error, field bindings and saves can end up targeting different cache objects, leading to silent data loss.

Where

  • src/data-fetching/docStore.tsgetDoc() / getKey()
  • src/data-fetching/useDoc/useDoc.ts — resolves name via toValue(name) once at call time (snapshot, non-reactive in the docStore binding path)

getKey(doctype, name) trims and concatenates, so name === '' produces the key "CRM Lead/", which is treated like any other document.

Root cause

getDoc resolves the name a single time and binds to whatever key results. When a component calls it before the document GET has resolved, name is '', so the binding points at the new-document/empty slot instead of the real document slot. This is the document-cache analogue of the empty-cacheKey guard in normalizeCacheKey (where '' correctly disables caching) — except here an empty identifier silently creates/aliases a shared bucket instead of being treated as "no document".

Impact / real-world repro

Observed and fixed downstream in frappe/crm#2282: the first edit of a freshly-loaded doc in a modal persisted no fields. Field.vue derived its change handler via useDocument(doctype, data.value.name), reading name at setup time. On first open the GET hadn't resolved, so data.name was empty and field edits wrote to the doctype/<empty> slot, while the modal's document.save read the real slot. The first set_value therefore shipped the unchanged (pristine) doc; subsequent opens worked only because the doc was warm in cache.

The CRM fix worked around it by threading an authoritative docname from the modal down to the fields. That is correct for CRM, but the underlying footgun lives here in useDoc/docStore and affects any consumer.

Steps to reproduce (conceptually)

  1. Mount a component that calls useDoc({ doctype, name }) where name is empty/unresolved at setup (e.g. bound to data.value.name before the document GET resolves).
  2. Mutate a field through the returned doc binding before the GET resolves.
  3. Save via a separate binding created with the resolved name.
  4. The first save serializes the pristine document — edits are lost.

Expected behavior

An empty/unresolved name should be treated as "no document", not as a real cache slot:

  • getDoc with a falsy name should return a stable null ref, fetch nothing, and bind nothing.
  • The genuine "new document" flow (useNewDoc) should keep its own explicit slot — reading an unnamed existing doc must not silently alias it.

Suggested hardening

  1. docStore (root fix): in getDoc, treat a falsy/trimmed-empty name as "not ready" — return a shared ref(null), do not create a doctype/ key, do not persist.
  2. Reactive name: make the returned doc re-resolve when name changes (resolve toValue(name) inside a computed/watch rather than once), so useDoc(doctype, () => data.value.name) self-heals when the GET lands and the workaround in CRM#2282 becomes unnecessary.
  3. Defense in depth: guard the mutation/trigger surface so changes are ignored (with a DEV warning) while name is unresolved, and expose an isReady flag callers can use to disable inputs.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingjavascriptPull requests that update javascript codetriagedSeen by barista

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions