Found while implementing #5939 (tightening object-master-detail-form.formType to a measured vocabulary). Not fixed there — #5939's scope is the declaration shape, this is a persistence path. Filed unassigned for triage.
Fact (objectui origin/main @ 6613f6fbc)
MasterDetailForm owns a single Save bar and routes the whole save — parent plus every detail row — through one dataSource.batchTransaction. It does that by handing the parent object-form a submitHandler (MasterDetailForm.tsx:612):
submitHandler: submitViaBatch,
submitHandler is read in exactly one place — ObjectForm.tsx:820, inside SimpleObjectForm:
if(schema.submitHandler){result=awaitschema.submitHandler(payload);TabbedForm, WizardForm, SplitForm, DrawerForm and ModalForm never read the key. TabbedForm.tsx:294 and its SplitForm counterpart persist directly instead:
result=awaitdataSource.create(schema.objectName,omitServerResolvedDefaults(data,objectSchema));
So when the parent half is rendered as anything other than the simple variant, the parent record is committed on its own and the batch — the entire reason this block exists (#2679, ADR-0034 item 4) — is bypassed. A failing child leg then leaves a committed, orphaned parent with no rollback.
Measured
Mounting object-master-detail-form with two parent sections and one detail collection, filling the parent field, then clicking the master-detail's own bottom Save bar. Counts are calls on the same stub dataSource:
parent formType | batchTransaction | dataSource.create | reading |
|---|
simple | 1 | 0 | atomic — correct |
tabbed | 0 | 1 (["po", {"ref":"PO-1"}]) | parent written outside the transaction |
split | 0 | 1 (["po", {"ref":"PO-1"}]) | same |
wizard | 0 | 0 | the Save bar drives the wizard's Next (observed: "Step 2 of 2") — nothing persists |
drawer / modal | 0 | 0 | parent half is in a portal dialog; the Save bar finds no form |
The simple / tabbed / split / wizard rows are pinned as regression coverage by packages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx (added by #5939), where they document why the vocabulary excludes four of the six.
Why it still matters after #5939
#5939 narrows object-master-detail-form.formType to simple | tabbed — the set the repo's own type (MasterDetailFormSchema.formType?: 'simple' | 'tabbed') and the ObjectForm.tsx:215 coercion already declare. That removes split / wizard / drawer / modal from the authoring surface, but tabbed stays declared and stays broken: an author who picks the tabbed presentation gets a non-atomic save and no signal. #5939 says so in its PR body rather than quietly declaring a value it knew was broken, and files this.
Triage question
Whether the fix is to make the variant forms honour submitHandler (the narrow, contract-preserving one — the key is already the declared seam), or to have MasterDetailForm stop delegating persistence to the parent form at all, or to drop tabbed from the vocabulary until the first is done. The first looks right: submitHandler is documented as the seam a host uses to own persistence, and five of six renderers silently ignoring a declared seam is the same class of defect as a declared-but-unread prop.
Related: #5939 (where this surfaced) · #2679 / ADR-0034 item 4 (the atomic-batch decision this leaks around).
Found while implementing #5939 (tightening
object-master-detail-form.formTypeto a measured vocabulary). Not fixed there — #5939's scope is the declaration shape, this is a persistence path. Filed unassigned for triage.Fact (objectui
origin/main@6613f6fbc)MasterDetailFormowns a single Save bar and routes the whole save — parent plus every detail row — through onedataSource.batchTransaction. It does that by handing the parentobject-formasubmitHandler(MasterDetailForm.tsx:612):submitHandleris read in exactly one place —ObjectForm.tsx:820, insideSimpleObjectForm:TabbedForm,WizardForm,SplitForm,DrawerFormandModalFormnever read the key.TabbedForm.tsx:294and itsSplitFormcounterpart persist directly instead:So when the parent half is rendered as anything other than the simple variant, the parent record is committed on its own and the batch — the entire reason this block exists (#2679, ADR-0034 item 4) — is bypassed. A failing child leg then leaves a committed, orphaned parent with no rollback.
Measured
Mounting
object-master-detail-formwith two parent sections and one detail collection, filling the parent field, then clicking the master-detail's own bottom Save bar. Counts are calls on the same stubdataSource:formTypebatchTransactiondataSource.createsimpletabbed["po", {"ref":"PO-1"}])split["po", {"ref":"PO-1"}])wizardNext(observed: "Step 2 of 2") — nothing persistsdrawer/modalThe
simple/tabbed/split/wizardrows are pinned as regression coverage bypackages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx(added by #5939), where they document why the vocabulary excludes four of the six.Why it still matters after #5939
#5939 narrows
object-master-detail-form.formTypetosimple | tabbed— the set the repo's own type (MasterDetailFormSchema.formType?: 'simple' | 'tabbed') and theObjectForm.tsx:215coercion already declare. That removessplit/wizard/drawer/modalfrom the authoring surface, buttabbedstays declared and stays broken: an author who picks the tabbed presentation gets a non-atomic save and no signal. #5939 says so in its PR body rather than quietly declaring a value it knew was broken, and files this.Triage question
Whether the fix is to make the variant forms honour
submitHandler(the narrow, contract-preserving one — the key is already the declared seam), or to haveMasterDetailFormstop delegating persistence to the parent form at all, or to droptabbedfrom the vocabulary until the first is done. The first looks right:submitHandleris documented as the seam a host uses to own persistence, and five of six renderers silently ignoring a declared seam is the same class of defect as a declared-but-unread prop.Related: #5939 (where this surfaced) · #2679 / ADR-0034 item 4 (the atomic-batch decision this leaks around).