Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .changeset/8738-object-form-fields-description.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@
---

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

The registration declared `{ name: 'fields', type: 'array' }` with no
description, so an author had nowhere to read that this key's members are
**bare field names** — a different vocabulary from `sections[].fields`, which
also accepts the spec `FormFieldSchema` object (identity key `field`, e.g.
`{ field: 'note', colSpan: 2 }`). Moving one of those objects to the top-level
`fields` resolves to no name and is silently skipped by `SimpleObjectForm`
`fields` resolves to no name and is skipped by `SimpleObjectForm`
(`ObjectForm.tsx`) and by `buildFlatFields` (`flatFields.ts`, shared by the
drawer/modal presentations) — no throw, no warning, no empty-state. Behaviour
is unchanged; this only adds the description text an author would need to
avoid the drop before writing it.
drawer/modal presentations). Behaviour is unchanged by this entry; it only adds
the description text an author would need to avoid the drop before writing it.

⚠️ Dated correction, so the original reading is not taken for the behaviour of
the release this publishes into. As written — `fd9bf26df0`, 2026-09-09T14:28:48Z
— that drop was SILENT: no throw, no warning, no empty-state. That reading was
true for 92 minutes. `8fda009057` (objectui#8859, route 1 of the same card) put
a de-duplicated `console.warn` at both named read sites at 2026-09-09T16:00:31Z,
so the drop is no longer silent. What did NOT change: the member is still
skipped, and there is still no throw and no empty-state.
42 changes: 42 additions & 0 deletions .changeset/9778-object-form-customfields-merge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@object-ui/plugin-form': minor
---

`object-form.customFields` now MERGES over the metadata-generated field set, as its
registered description always promised (objectui#9778, maintainer ruling 2026-09-18,
director seat batch #161 item 5).

**Behaviour change, deliberately.** Hosts that used `customFields` as a full
replacement now see the metadata-generated members too. Before this change a
non-empty `customFields` replaced the generated set outright and the object's schema
was never even fetched; the registration has always described the other thing —
"Field definitions merged over the set generated from object metadata. With inline
definitions and no data source, this becomes the only field source." The per-member
merge the prose describes already existed as code in `ObjectForm` — the
`customFields?.find((f) => f.name === name)` lookup inside the metadata branch — and
was unreachable, because that branch ran only when `customFields` was absent or
empty, i.e. only when the lookup had nothing to find.

The merge takes three directions, one pinned case each in
`objectFormCustomFieldsMembers-8071.test.tsx`:

- **override** — a member naming a declared field supplies that field's whole
definition, in the generated set's position, inheriting nothing from it;
- **keep** — a declared field no member names still renders, from object metadata;
- **append** — a member naming a field the metadata never declares is added after
the generated set, in authored order.

**Unchanged where there is nothing to merge over.** With no data source (or no
`objectName`) there is no generated set, so the members remain the only field
source — the registration's second sentence, and the shape `EmbeddableForm` uses,
which deliberately passes no data source once inline members are present. An object
the adapter cannot describe now falls back to that same members-only source rather
than replacing a form that used to render with an error panel.

**Migration.** ⛔ No "replace mode" option is added: a host that wants a bespoke set
declares its own form. Two routes exist for a host that wants exactly its member
list against an object that HAS metadata, both through the existing `fields`
whitelist, which narrows the generated set the members merge over (measured):
`fields: []` leaves the members as the whole set, and `fields: ['customer']` renders
that one generated field plus the members. Authors who intended the documented merge
all along need to change nothing.
92 changes: 72 additions & 20 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -636,12 +636,26 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
useEffect(() => {
if (hasInlineFields) {
setInitialData(schema.initialData || schema.initialValues || {});
setLoading(false);
// objectui#9778: inline members no longer short-circuit the metadata
// read — they MERGE over it — so the loading flag can only drop here
// when nothing is going to be fetched. Dropping it unconditionally put
// an empty `<form>` on screen for the duration of `getObjectSchema`.
// The metadata branch's own tail (`willFetchData`) clears it otherwise.
if (!(schema.objectName && dataSource)) {
setLoading(false);
}
}
}, [hasInlineFields, schema.initialData, schema.initialValues]);
}, [hasInlineFields, schema.initialData, schema.initialValues, schema.objectName, dataSource]);

// Fetch object schema from ObjectQL/ObjectStack (skip if using inline fields)
// Fetch object schema from ObjectQL/ObjectStack (inline members merge OVER it)
useEffect(() => {
// The field source when no object metadata is reachable: an object with no
// fields, over which the authored members are the whole set.
const inlineOnlySchema = {
name: schema.objectName,
fields: {} as Record<string, any>,
};

const fetchObjectSchema = async () => {
try {
if (!dataSource) {
Expand All @@ -653,21 +667,34 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
}
setObjectSchema(schemaData);
} catch (err) {
// objectui#9778: for the inline path the metadata is an OVERLAY, not a
// prerequisite. A form that renders its authored members today must not
// become an error panel because the adapter cannot describe the object —
// fall back to the members-only source the registration promises for the
// no-data-source case.
if (hasInlineFields) {
setObjectSchema(inlineOnlySchema);
setLoading(false);
return;
}
setError(err as Error);
setLoading(false);
}
};

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

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

if (!objectSchema) return;

const generatedFields: FormField[] = [];
Expand Down Expand Up @@ -973,6 +1011,20 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
}
});

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

setFormFields(generatedFields);

// Only set loading to false if we are not going to fetch data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
* of racing RTL's 1000ms `findBy` budget against a cold Vite transform.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
import React from 'react';
import { registerAllFields } from '@object-ui/fields';
import type { ObjectFormSchema } from '@object-ui/types';
Expand Down Expand Up @@ -239,7 +239,14 @@ describe('ObjectForm mobile.fullscreenLongText → RichTextField (objectui#3301)
richTextObjectSchema,
);

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

fireEvent.change(screen.getByTestId('richtext-fullscreen-input'), {
Expand Down
Loading
Loading