Skip to content

Commit 5a311a3

Browse files
os-salesclaude
andauthored
fix(plugin-form): merge object-form.customFields over the metadata-generated field set (#9948)
Fixes #9778 Clause-②: no `object-form.customFields` now MERGES over the metadata-generated field set, per the director-seat ruling on the card (batch #161 item 5, summon #24, maintainer 「其他同意」 2026-09-18T12:07Z). The registration description is the contract and is **unchanged**. Clause-② tested against this diff, not inherited: the diff adds **0** lines containing `export ` (lit control on the same corpus: 18 added lines contain `customFields`) and no new key on any published payload — an existing branch is made reachable. Answer stands at `no`. ## What changed `ObjectForm.tsx`, three sites: 1. **The replace path is gone.** A non-empty `customFields` used to run `setFormFields(schema.customFields.map(normalizeVisibility))` and return above the metadata branch, so the object's schema was never even fetched. 2. **The metadata is now fetched whenever an `objectName` and a data source are present**, inline members or not — without the generated set there is nothing to merge over, and the merge lookup `schema.customFields?.find((f) => f.name === name)` stays the dead code objectui#8071 measured. The members-only schema (an object with no fields) became the **fallback** rather than the inline path's fixed answer: it is what the registration's second sentence describes, "with inline definitions and no data source, this becomes the only field source". 3. **Members naming nothing in the generated set are appended** after it, in authored order, right before `setFormFields(generatedFields)`. Two smaller consequences, both deliberate: - The loading flag no longer drops unconditionally on the inline path — it drops when nothing is going to be fetched, otherwise the metadata branch's own `willFetchData` tail clears it. Without this an empty `form` sat on screen for the duration of `getObjectSchema`. - A `getObjectSchema` that throws (or returns nothing) now falls back to the members-only source **when inline members are present**, instead of replacing a form that used to render with an error panel. The metadata is an overlay on this path, not a prerequisite. ## The pins, one row per direction `objectFormCustomFieldsMembers-8071.test.tsx` flips from pinning the replace behaviour to pinning the merge: | row | direction | asserts | | --- | --- | --- | | 1 | shape + merge | members are whole field definitions keyed by `name`; the merged set is `customer, note, amount, zz` | | 2 | **override** | a member takes the declared field's position, supplies its whole definition, **and the metadata IS fetched** — `getObjectSchema` inverted from `toBe(0)` to `toBe(1)` | | 3 | **keep** | `customer` / `amount`, which no member names, still render with the object's own labels | | 4 | **append** | members naming nothing declared come after the generated set, in authored order | | 5 | (unchanged) | an EMPTY `customFields` is UNAUTHORED — the read is `.length > 0`, not truthiness | | 6 | (unchanged) | the same member count exempts the block from the no-adapter panel; doubles as the control for the no-data-source sentence | One collateral red, repaired in place and called out here: `ObjectForm.mobileFullscreen.test.tsx`'s "commits a fullscreen edit back into the form state" mounts a `customFields` member named `summary` against an object that also declares `changelog`, so the merge draws two expand affordances and an unscoped `findByTestId` found both. The lookup is now scoped to the field the case is about; nothing else in the file moved. ## Reverse verification (ablation) The fix was committed first, then `ObjectForm.tsx` alone was reverted to this branch's base commit and the flipped pins re-run: - mutation landed on disk: the `objectui#9778` markers went 5 to 0 and the replace path `setFormFields(schema.customFields.map(normalizeVisibility))` came back (1 occurrence); blob `85d6c1c4` against HEAD's `cd4bd869` - result: **4 failed | 2 passed (6)** — rows 1-4 red, each for its own reason (`expected [ 'zz', 'note' ] to deeply equal [ 'customer', 'note', 'amount', 'zz' ]`; `expected +0 to be 1`; `expected null to be 'Customer'`; `expected [ 'zz', 'yy' ] to deeply equal [ 'customer', 'note', 'amount', …(2) ]`), rows 5-6 green as they should be, since neither is about the merge - restored by `git checkout HEAD -- ObjectForm.tsx` (trap-guarded, absolute path): blob back to `cd4bd869`, `git diff HEAD` empty, markers back to 5 ## Tests Run at `096496e052`: - `pnpm --filter @object-ui/plugin-form test` — **100 files passed, 940 passed | 1 skipped (941)** - `pnpm --filter @object-ui/plugin-form type-check` — exit 0 (`tsc --noEmit` plus `tsconfig.test.json`), after `pnpm --filter '@object-ui/plugin-form^...' build` — without that dependency-closure build it reports `TS2307 Cannot find module '@object-ui/*'` on every workspace import, which is a missing `dist`, not a type error - consumers that pass `customFields` through: `packages/plugin-view/` + `packages/plugin-designer/` + `registry-inputs-spec-parity.test.ts` + `public-block-binding-reach.test.tsx` — **66 files, 757 passed**. The fenced ledger's counts do not move, exactly as the dispatch predicted, so it was **not edited**. - gates, each exit code captured before any pipe: `check:control-bytes`, `check:new-line-citations`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:element-data-source-declaration`, `check-changeset-presence.mjs`, `check-changeset-no-major.mjs` — all exit 0. `eslint` on the three changed files: 0 errors (64 pre-existing `no-explicit-any` warnings). - `check:sdui-registration-pins` — **NOT MEASURED**: exit 2 is its own prerequisite refusal ("No console build to weigh at apps/console/dist/assets"), not a red. This diff adds and removes no registration; CI builds the console and runs it. ## Acceptance notes Out-of-scope findings, neither touched here: 1. **To file (class b).** The same registered description still describes the drawer and modal presentations of this block, and both still replace. `ObjectForm` spreads the schema — `customFields` included — into `DrawerForm` / `ModalForm` for `formType: 'drawer' | 'modal'`, and each of those runs its own `if (schema.customFields?.length) { setFormFields(schema.customFields); }`. So an author who takes the registration at its word gets a merge on the flat presentation and a replacement on the other two, from one declared key. Out of this card's file surface (the ruling names `ObjectForm.tsx`) and it needs its own pins. Dedupe words: `DrawerForm customFields replace` · `ModalForm customFields replace` · `presentation arms merge parity` · `formType drawer inline fields` · `customFields merge unreachable`. 2. **Noted, not filed.** The console ledger entry for `object-form.customFields` still describes the replace behaviour in prose ("they REPLACE the metadata-generated set … `getObjectSchema` asserted at zero calls … handed back as a finding rather than repaired"). It is reviewed prose, not a computed assertion — its counts do not move and its test stays green (measured above) — and the file is held by open PR objectui#9924, so it was not edited. Successor: whoever lands objectui#9924, or the next objectui#8071 slice that edits that ledger. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- _Generated by [Claude Code](https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6a8444 commit 5a311a3

5 files changed

Lines changed: 210 additions & 68 deletions

File tree

‎.changeset/8738-object-form-fields-description.md‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,24 @@
33
---
44

55
`object-form`'s top-level `fields` input now documents its member vocabulary
6-
(objectui#8738, route 2 of 2 — route 1, a diagnostic `console.warn`, is a
7-
separate ruling still pending).
6+
(objectui#8738, route 2 of 2 — route 1, a diagnostic `console.warn`, was a
7+
separate ruling still pending when this was written; it landed 92 minutes
8+
later, see the dated note below).
89

910
The registration declared `{ name: 'fields', type: 'array' }` with no
1011
description, so an author had nowhere to read that this key's members are
1112
**bare field names** — a different vocabulary from `sections[].fields`, which
1213
also accepts the spec `FormFieldSchema` object (identity key `field`, e.g.
1314
`{ field: 'note', colSpan: 2 }`). Moving one of those objects to the top-level
14-
`fields` resolves to no name and is silently skipped by `SimpleObjectForm`
15+
`fields` resolves to no name and is skipped by `SimpleObjectForm`
1516
(`ObjectForm.tsx`) and by `buildFlatFields` (`flatFields.ts`, shared by the
16-
drawer/modal presentations) — no throw, no warning, no empty-state. Behaviour
17-
is unchanged; this only adds the description text an author would need to
18-
avoid the drop before writing it.
17+
drawer/modal presentations). Behaviour is unchanged by this entry; it only adds
18+
the description text an author would need to avoid the drop before writing it.
19+
20+
⚠️ Dated correction, so the original reading is not taken for the behaviour of
21+
the release this publishes into. As written — `fd9bf26df0`, 2026-09-09T14:28:48Z
22+
— that drop was SILENT: no throw, no warning, no empty-state. That reading was
23+
true for 92 minutes. `8fda009057` (objectui#8859, route 1 of the same card) put
24+
a de-duplicated `console.warn` at both named read sites at 2026-09-09T16:00:31Z,
25+
so the drop is no longer silent. What did NOT change: the member is still
26+
skipped, and there is still no throw and no empty-state.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@object-ui/plugin-form': minor
3+
---
4+
5+
`object-form.customFields` now MERGES over the metadata-generated field set, as its
6+
registered description always promised (objectui#9778, maintainer ruling 2026-09-18,
7+
director seat batch #161 item 5).
8+
9+
**Behaviour change, deliberately.** Hosts that used `customFields` as a full
10+
replacement now see the metadata-generated members too. Before this change a
11+
non-empty `customFields` replaced the generated set outright and the object's schema
12+
was never even fetched; the registration has always described the other thing —
13+
"Field definitions merged over the set generated from object metadata. With inline
14+
definitions and no data source, this becomes the only field source." The per-member
15+
merge the prose describes already existed as code in `ObjectForm` — the
16+
`customFields?.find((f) => f.name === name)` lookup inside the metadata branch — and
17+
was unreachable, because that branch ran only when `customFields` was absent or
18+
empty, i.e. only when the lookup had nothing to find.
19+
20+
The merge takes three directions, one pinned case each in
21+
`objectFormCustomFieldsMembers-8071.test.tsx`:
22+
23+
- **override** — a member naming a declared field supplies that field's whole
24+
definition, in the generated set's position, inheriting nothing from it;
25+
- **keep** — a declared field no member names still renders, from object metadata;
26+
- **append** — a member naming a field the metadata never declares is added after
27+
the generated set, in authored order.
28+
29+
**Unchanged where there is nothing to merge over.** With no data source (or no
30+
`objectName`) there is no generated set, so the members remain the only field
31+
source — the registration's second sentence, and the shape `EmbeddableForm` uses,
32+
which deliberately passes no data source once inline members are present. An object
33+
the adapter cannot describe now falls back to that same members-only source rather
34+
than replacing a form that used to render with an error panel.
35+
36+
**Migration.** ⛔ No "replace mode" option is added: a host that wants a bespoke set
37+
declares its own form. Two routes exist for a host that wants exactly its member
38+
list against an object that HAS metadata, both through the existing `fields`
39+
whitelist, which narrows the generated set the members merge over (measured):
40+
`fields: []` leaves the members as the whole set, and `fields: ['customer']` renders
41+
that one generated field plus the members. Authors who intended the documented merge
42+
all along need to change nothing.

‎packages/plugin-form/src/ObjectForm.tsx‎

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -636,12 +636,26 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
636636
useEffect(() => {
637637
if (hasInlineFields) {
638638
setInitialData(schema.initialData || schema.initialValues || {});
639-
setLoading(false);
639+
// objectui#9778: inline members no longer short-circuit the metadata
640+
// read — they MERGE over it — so the loading flag can only drop here
641+
// when nothing is going to be fetched. Dropping it unconditionally put
642+
// an empty `<form>` on screen for the duration of `getObjectSchema`.
643+
// The metadata branch's own tail (`willFetchData`) clears it otherwise.
644+
if (!(schema.objectName && dataSource)) {
645+
setLoading(false);
646+
}
640647
}
641-
}, [hasInlineFields, schema.initialData, schema.initialValues]);
648+
}, [hasInlineFields, schema.initialData, schema.initialValues, schema.objectName, dataSource]);
642649

643-
// Fetch object schema from ObjectQL/ObjectStack (skip if using inline fields)
650+
// Fetch object schema from ObjectQL/ObjectStack (inline members merge OVER it)
644651
useEffect(() => {
652+
// The field source when no object metadata is reachable: an object with no
653+
// fields, over which the authored members are the whole set.
654+
const inlineOnlySchema = {
655+
name: schema.objectName,
656+
fields: {} as Record<string, any>,
657+
};
658+
645659
const fetchObjectSchema = async () => {
646660
try {
647661
if (!dataSource) {
@@ -653,21 +667,34 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
653667
}
654668
setObjectSchema(schemaData);
655669
} catch (err) {
670+
// objectui#9778: for the inline path the metadata is an OVERLAY, not a
671+
// prerequisite. A form that renders its authored members today must not
672+
// become an error panel because the adapter cannot describe the object —
673+
// fall back to the members-only source the registration promises for the
674+
// no-data-source case.
675+
if (hasInlineFields) {
676+
setObjectSchema(inlineOnlySchema);
677+
setLoading(false);
678+
return;
679+
}
656680
setError(err as Error);
657681
setLoading(false);
658682
}
659683
};
660684

661-
// Skip fetching if we have inline fields
662-
if (hasInlineFields) {
663-
// Use a minimal schema for inline fields
664-
setObjectSchema({
665-
name: schema.objectName,
666-
fields: {} as Record<string, any>,
667-
});
668-
} else if (schema.objectName && dataSource) {
685+
// objectui#9778: inline members are "merged over the set generated from
686+
// object metadata" (the registered description of `customFields`), so the
687+
// fetch is no longer skipped when they are present — without the generated
688+
// set there is nothing to merge over and the merge lookup further down
689+
// stays the dead code objectui#8071 measured. The members-only schema is
690+
// what the registration's second sentence describes ("with inline
691+
// definitions and no data source, this becomes the only field source"), so
692+
// it is now the FALLBACK rather than the inline path's fixed answer.
693+
if (schema.objectName && dataSource) {
669694
fetchObjectSchema();
670-
} else if (!hasInlineFields) {
695+
} else if (hasInlineFields) {
696+
setObjectSchema(inlineOnlySchema);
697+
} else {
671698
// No objectName or dataSource and no inline fields — cannot proceed
672699
setLoading(false);
673700
}
@@ -718,15 +745,26 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
718745
// matcher, which is not a CEL evaluator and was never called downstream.
719746
const normalizeVisibility = useCallback((f: any): any => f, []);
720747

721-
// Generate form fields from object schema or inline fields
748+
// Generate form fields from object schema, with inline members merged over it
749+
//
750+
// objectui#9778 — `customFields` MERGES, it does not replace. The registered
751+
// description of the member ("Field definitions merged over the set generated
752+
// from object metadata. With inline definitions and no data source, this
753+
// becomes the only field source.") is the contract, and the per-member merge
754+
// below (`schema.customFields?.find(...)`) was written for it — but a
755+
// non-empty `customFields` used to return from here with
756+
// `setFormFields(schema.customFields.map(normalizeVisibility))` BEFORE the
757+
// generated set existed, so that lookup only ever ran over an empty array.
758+
// The three directions the merge now takes, one case each in
759+
// `objectFormCustomFieldsMembers-8071.test.tsx`:
760+
// override — a member naming a declared field replaces that field's
761+
// generated definition, in the generated set's position;
762+
// keep — a declared field no member names still renders;
763+
// append — a member naming a field the metadata never declares is added
764+
// after the generated set, in authored order.
765+
// With no data source the generated set is empty, so the members remain the
766+
// only field source (the registration's second sentence) — unchanged.
722767
useEffect(() => {
723-
// For inline fields, use them directly
724-
if (hasInlineFields && schema.customFields) {
725-
setFormFields(schema.customFields.map(normalizeVisibility));
726-
setLoading(false);
727-
return;
728-
}
729-
730768
if (!objectSchema) return;
731769

732770
const generatedFields: FormField[] = [];
@@ -973,6 +1011,20 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
9731011
}
9741012
});
9751013

1014+
// objectui#9778 — the APPEND direction. A member naming a field the
1015+
// generated set does not carry (an object metadata never declared, or one
1016+
// the `fields` whitelist left out) is added after it, in authored order;
1017+
// members that already overrode a generated field above are not repeated.
1018+
if (hasInlineFields && schema.customFields) {
1019+
const alreadyDrawn = new Set(generatedFields.map((f) => f.name));
1020+
schema.customFields.forEach((customField: any) => {
1021+
const name = customField?.name;
1022+
if (!name || alreadyDrawn.has(name)) return;
1023+
alreadyDrawn.add(name);
1024+
generatedFields.push(normalizeVisibility(customField));
1025+
});
1026+
}
1027+
9761028
setFormFields(generatedFields);
9771029

9781030
// Only set loading to false if we are not going to fetch data

‎packages/plugin-form/src/__tests__/ObjectForm.mobileFullscreen.test.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
* of racing RTL's 1000ms `findBy` budget against a cold Vite transform.
4747
*/
4848
import { describe, it, expect, vi } from 'vitest';
49-
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
49+
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
5050
import React from 'react';
5151
import { registerAllFields } from '@object-ui/fields';
5252
import type { ObjectFormSchema } from '@object-ui/types';
@@ -239,7 +239,14 @@ describe('ObjectForm mobile.fullscreenLongText → RichTextField (objectui#3301)
239239
richTextObjectSchema,
240240
);
241241

242-
const toggle = await screen.findByTestId('richtext-fullscreen-toggle');
242+
// objectui#9778 — `customFields` MERGES over the metadata-generated set, so
243+
// this form draws the object's OTHER rich-text field (`changelog`) too and
244+
// there are two expand affordances on screen. The case is about `summary`,
245+
// so the lookup is scoped to it; before the merge this member was the whole
246+
// field set and an unscoped `findByTestId` happened to be unambiguous.
247+
await waitFor(() => expect(inlineControl('summary')).toBeInTheDocument());
248+
const summaryField = document.querySelector('[data-field="summary"]') as HTMLElement;
249+
const toggle = within(summaryField).getByTestId('richtext-fullscreen-toggle');
243250
fireEvent.click(toggle);
244251

245252
fireEvent.change(screen.getByTestId('richtext-fullscreen-input'), {

0 commit comments

Comments
 (0)