…hen `sections` is given — correct the declaration and name every loss
The registration declared `fields` "Ignored when `sections` is given — sections
carry their own field lists". It is not ignored: `SimpleObjectForm` builds the
parent field pool from `schema.fields` first and each section resolves its own
members against that pool, so authoring both INTERSECTS them. A section member
outside `fields` did not render, and a section that lost every member
disappeared with its heading — silently.
Ruled from the tree: the DECLARATION was the wrong half.
- One renderer. `MasterDetailForm`'s `parentSchema` memo builds a
`{ type: 'object-form', ... }` node and renders it through a directly
imported `<ObjectForm>`, so this block's parent form IS an `object-form`
and no code can honour the exemption for one without forking the two.
- Three of the package's four `fields` registrations — `object-form`,
`form`, `embeddable-form` — declare the key as the field selection with no
such exemption, and `objectFormFieldsMembers-8071` pins it as one.
Honouring the exemption would falsify three declarations to satisfy one.
- The pool is not only the layout: it also feeds create defaults, the
`initialValues` merge and the values a submit carries, so ignoring
`fields` would change what a landed schema writes, not just what it draws.
So the intersection stands and the rendered outcome does not move. What moves
is the silence: `warnSectionMemberExcludedByFields` names the section, the
member and the two keys that collided, once per distinct pair, whenever a
member the object really declares is dropped for the sole reason that `fields`
omits it. A member the object never declares is deliberately left out of it —
that member resolves to nothing whether or not `fields` is authored, and it is
pinned as a separate silence by row 2 of both `sections` member pins.
The `object-master-detail-form.sections` pin moves in the same commit: its
sharp row keeps the two DOM assertions objectui#8071 slice 15 wrote and gains
the warning legs, a firing control, and a leg that keeps the warning off the
other silence. Nothing in it was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Fixes #9884
object-master-detail-form'sfieldsregistration declared itself "Ignored whensectionsis given — sections carry their own field lists". It is not ignored: the two INTERSECT, and a section member outsidefieldsvanished silently — heading and all when it was that section's last one.The fork: I took (b) — the runtime is right, the declaration was wrong
The card offered (a) make
fieldsgenuinely ignored, or (b) correct the sentence, and leaned (a). The tree rules (b), on four readings taken on this branch's base1ed2e69fa:object-form.MasterDetailForm'sparentSchemamemo (MasterDetailForm.tsx:971) literally builds a node whosetypeis the stringobject-form, copiessectionsandfieldsonto it, and renders it through a directly importedObjectFormelement — no registry lookup. So nothing in the renderer can honour the exemption for this block without honouring it forobject-formtoo, or forking two blocks whose parent schema is byte-identical.fieldsregistrations declare the opposite.object-form(index.tsx:232),form(:292) andembeddable-form(:346) all declarefieldsas "Bare field names to show, in order", with no exemption — andobject-formdeclaressectionsin the same block (:235).objectFormFieldsMembers-8071pinsobject-form.fieldson authored order being preserved, i.e. on it being a real selection. Honouring:430would falsify three declarations to satisfy one.MasterDetailFormSchema(MasterDetailForm.tsx:90-92) documentssectionsandfieldsas "Parent form sections/fields — passed straight through to ObjectForm". That is true today and false under (a).fieldsbuildsformFields(ObjectForm.tsx:802,fieldsToShow), which also feeds create defaults, theinitialValuesmerge,gatedFormFieldsand the values a submit carries. Ignoring it would change what a landed schema writes, not just what it draws.['ref','memo','memo']— as "the double render the declaration would produce if it were true", i.e. as a hint that (a) is undesirable. Measured on the base, it is not evidence aboutfieldsat all. Two sections naming the same field double-render it today, and a schema writing onlysections— exactly the write the declaration encourages — is enough:fields: ['ref','memo']+ sectionsHeader:[ref,memo],Notes:[memo]['ref','memo','memo']fieldsat all + the same two sections['ref','memo','memo']fields: ['ref']+ the same two sections (the pinned row)['ref']So
['ref','memo','memo']is the pre-existing consequence of duplicate section membership, whichfields: ['ref']happened to mask in that fixture. It neither supports nor opposes (a). The fork was decided on 1-4 above instead.What was measured about the blast radius
The triage seat asked for a count before anyone picks a side: how many schemas write both keys. Within reach of this branch — all 635 JSON documents in the tree, walked structurally:
sectionsandfields;fieldsalone;sectionsalone (examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json) — the lit control, so the instrument does find the shape it is looking for.And every in-repo producer treats them as alternatives:
resolveFormViewLayoutemitssectionsand neverfields, anduseActionModalspells the choice explicitly as(d.fields || d.sections) ? {} : resolveFormViewLayout(...). Radius: in-repo JSON and in-repo producers only; schemas landed in customer trees are outside any instrument I have. The triage escalation condition (at least one real schema losing fields today ⇒ p1) is not met on this population, and that is a bounded reading, not a clean bill.The re-taken line numbers
packages/plugin-form/src/index.tsx:430— thefieldsregistration description. Unmoved, and verbatim as the claim quotes it.packages/plugin-form/src/ObjectForm.tsx:1473— the intersection,applyFieldPerms(sourceFields.filter(f => sectionFieldNames.includes(f.name))). The claim's correction stands: the card's:1387is stale.ObjectForm.tsx:802:const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {}). That is the whole mechanism — with nofields, the pool is every declared field and the intersection is a no-op.What changed
The sentence at
:430now states the intersection, the two consequences, and that each drop is reported.The silence — the half that was a defect under either reading — is now shut.
warnSectionMemberExcludedByFieldsjoins the two warnings already insectionFields.ts(objectui#3090's mixed-vocabulary lint and objectui#8738's unresolved-member lint), same voice, same once-per-occurrence dedupe. It fires only when a member the object really declares is dropped for the sole reason thatfieldsomits it, and it is measured beforeapplyFieldPerms, so a field removed by per-caller permissions is never reported as an authoring mistake.Deliberately not recruited: a member the object never declares at all (a typo, or a detail-column borrowed into the parent's vocabulary). That resolves to nothing whether or not
fieldsis authored — a different silence with a different remedy, pinned as behaviour by row 2 of bothsectionsmember pins. Row 3d keeps the new warning off it.No rendered outcome moved. The intersection stands.
The slice-15 pin: before and after
packages/plugin-form/src/__tests__/masterDetailSectionMembers-8071.test.tsx, row 3, titled "fieldsis NOT ignored whensectionsis given — the two INTERSECT, and the loser is silent".Before it asserted, for
fields: ['ref']with sectionsHeader:['ref','memo']andNotes:['memo']:drawnFieldsis['ref'],headingsis['Header']. Its docblock recorded the row as a DIVERGENCE from the block's own registration, handed back as a finding and "not fixed here, because fixing it changes either the renderer or the declaration, and this card writes pins only".After it asserts those same two DOM outcomes, on the same fixture, unchanged — plus: both losses are named, one warning per (section, member) pair, each naming the section and saying which two keys collided. Row 3b isolates the last-member case on its own fixture and carries a counterfactual leg (drop
fields, and the eaten member renders under its heading). Row 3c is the firing control (fieldslisting every member warns about nothing). Row 3d is the leg that keeps the warning off the other silence.That is a strengthening, not a relaxation: the row went from 2 assertions to 9, kept both originals byte-for-byte, and the only thing deleted was the phrase "the loser is silent" — deleted because this change made it false. The pin's divergence-from-the-registration framing is replaced by the ruling, in the same commit that makes the registration true.
⭐ Row 3b also found a consequence sharper than the card states: when the vanishing section is the form's only section, the parent half draws no control at all. The grouped branch renders only what the sections resolved, so
ref— present infields, named by no section — never appears either. The row pins that with the counterfactual beside it.Ablation — the red leg, from the committed fix
Mutation: the diagnostic's guard in
ObjectForm.tsxdisabled (if (false && ...)), restoring the pre-fix silent behaviour exactly.On-disk proof, not an exit code:
Red leg:
Tests 2 failed | 5 passed (7)— exactly rows 3 and 3b, and within them exactly the warning assertions (expected +0 to be 2,expected +0 to be 1). Rows 1, 2, 4 and the two firing controls 3c/3d stayed green, and the DOM assertions inside rows 3 and 3b did not move — which is the shape that proves the ablation removed the diagnostic and nothing else.Restore proven by blob hash and an empty diff, never by an exit code:
Green leg, re-run from the restored tree:
Test Files 1 passed (1) · Tests 7 passed (7).Checks run on this branch
pnpm exec vitest run packages/plugin-form/(103 files)993 passed, 1 skipped — under the shared verify lock,VERDICT command-exit 0pnpm exec vitest run apps/console/src/__tests__/registry-inputs-spec-parity.test.ts199 passed— the gate that reads every registration'sinputspnpm --filter @object-ui/plugin-form type-checkeslint --no-inline-configon the 4 changed sourcesno-explicit-anywarnings)check:control-bytes·check:new-line-citations·check:changeset-claims·check:pending-changeset-literals·check:comment-mask-corpus·check:test-path-rootsnode scripts/check-changeset-presence.mjsnode scripts/check-governed-queue-guard.mjs --teston the 5 pathscheck:sdui-registration-pinsPREREQUISITE NOT MET(it weighsapps/console/dist, which is not built here). It counts bundle chunks per registration KEY; this change moves a description string only, no key. Declared to CI.The repo-wide scans (
pnpm lint,pnpm test) are CI's run, not this branch's.Acceptance notes
Outside the declared file face — reported, not ridden. The member-pin ledger in
apps/console/src/__tests__/registry-inputs-spec-parity.test.tscarries prose that this change makes stale, in two places: theobject-master-detail-form.sectionsentry (:2443) quotes the old sentence, says the drop happens "with no diagnostic", and records the finding as "not fixed"; a second passage (:3405) repeats the quote. Neither is machine-compared to the registration —memberPinProblemonly checks that the pin file exists and names the block and key — so nothing turns red, but the ledger now describes a tree that no longer exists. The declared face for this card ispackages/plugin-form/src/**and.changeset/*, so I did not touch it. It wants a follow-up, or an extension of the face.Observed, not filed.
object-form's ownfieldsdescription (index.tsx:232) says nothing about the interaction withsections— silence, not a false claim, so it is not the defect class this card carries; a sentence there would help authors and belongs to whoever next opens that block. And duplicate section membership double-renders a field (two sections naming it draw two bound controls) — pre-existing, reachable withsectionsalone, measured in the table above; it is a separate question from this one and no PR here touches it.🤖 Generated with Claude Code
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Generated by Claude Code