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.ts — getDoc() / 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)
- 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).
- Mutate a field through the returned doc binding before the GET resolves.
- Save via a separate binding created with the resolved name.
- 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
- 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.
- 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.
- 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
Summary
In the
src/data-fetchinglayer,docStore.getDoc()(and thereforeuseDoc/ consumers like CRM'suseDocument) accepts an empty or not-yet-resolvednameand 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.ts—getDoc()/getKey()src/data-fetching/useDoc/useDoc.ts— resolvesnameviatoValue(name)once at call time (snapshot, non-reactive in the docStore binding path)getKey(doctype, name)trims and concatenates, soname === ''produces the key"CRM Lead/", which is treated like any other document.Root cause
getDocresolves the name a single time and binds to whatever key results. When a component calls it before the document GET has resolved,nameis'', 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-cacheKeyguard innormalizeCacheKey(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.vuederived its change handler viauseDocument(doctype, data.value.name), readingnameat setup time. On first open the GET hadn't resolved, sodata.namewas empty and field edits wrote to thedoctype/<empty>slot, while the modal'sdocument.saveread the real slot. The firstset_valuetherefore 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
docnamefrom the modal down to the fields. That is correct for CRM, but the underlying footgun lives here inuseDoc/docStoreand affects any consumer.Steps to reproduce (conceptually)
useDoc({ doctype, name })wherenameis empty/unresolved at setup (e.g. bound todata.value.namebefore the document GET resolves).Expected behavior
An empty/unresolved
nameshould be treated as "no document", not as a real cache slot:getDocwith a falsynameshould return a stablenullref, fetch nothing, and bind nothing.useNewDoc) should keep its own explicit slot — reading an unnamed existing doc must not silently alias it.Suggested hardening
getDoc, treat a falsy/trimmed-emptynameas "not ready" — return a sharedref(null), do not create adoctype/key, do not persist.namechanges (resolvetoValue(name)inside acomputed/watchrather than once), souseDoc(doctype, () => data.value.name)self-heals when the GET lands and the workaround in CRM#2282 becomes unnecessary.nameis unresolved, and expose anisReadyflag callers can use to disable inputs.References
normalizeCacheKeyalready disables caching for an emptycacheKey; the document path should apply the same "empty identifier is the off path" principle.