Skip to content

Commit a260556

Browse files
fix(types,plugin-form)!: close FormField's open index signature; a form-view field's immutable locks the control on edit (#28363)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoGLEEchZXiDquUn9Vikz3oJ)_ Fixes #25923 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — no `skills/**` page or docs page teaches `FormField` or its index signature (`grep -rn FormField skills/` is empty); the closed-face rule `skills/objectui/rules/protocol.md` already states for node types now holds for `FormField` too, and the spec's own `immutable` description ("editable on create, locked once the record exists") is what the renderer now does. ## 维护者速读 - 改了什么:`@object-ui/types` 的 `FormField` 去掉了 `[key: string]: any` 开放索引签名,拼错或未声明的键在 TS 里直接报错;表单视图字段的 `immutable: true` 现在真的生效:编辑已有记录时该控件锁定,新建时仍可编辑。 - 为什么改:开放签名让拼写错误和宿主私写的键(如 `group`)都能静默通过;`immutable` 以前只是被抄到运行时字段上,没有任何渲染器读它,编辑表单里照样能改。这是本卡最后两项(席位裁决 A 与 immutable 跟进)。 - 风险与回滚:仅编译期收窄,运行时只多了一处行为(编辑模式下 immutable 字段禁用);zod 镜像和严格授权面不变,spec 的接受集合不变。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-form/src/sectionFields.ts`(immutable 那一行)、`ui/packages/plugin-form/src/fieldGroups.ts`(两个本地交接类型)、`ui/packages/types/src/form.ts`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-form/src/sectionFields.spec-parity.test.ts packages/types/src/__tests__/form-field-closed-face-25923.test.ts`,预期全绿。 - 需要你做的:无,席位落地。 ## What changes **`FormField` is a closed authoring face** (seat ruling 6412606977, letter A). `ui/packages/types/src/form.ts` drops `[key: string]: any` from `FormField`. Every declared member is unchanged. The zod mirror `FormFieldSchema` and the strict face `StrictAnyComponentSchema` are unchanged: they already refused undeclared keys. The readers the drop broke are fixed by declaring each key where its producer and reader live (ui `AGENTS.md` #0.1), never with a cast to `any` or `Record<string, unknown>`: - **`@object-ui/core`** `FieldWithDeclaredDefault` (the parameter type of `isServerOwnedValue`) drops its own `[key: string]: unknown`. An interface without an index signature is not assignable to a type that has one, so every `FormField` call site of `isServerOwnedValue` broke on it: the 5 in components `form.tsx` and the 1 in plugin-form `WizardForm.tsx` measured on 2026-10-26 clear with this one change. The 2 plugin-grid errors measured then do not reproduce: `ObjectGrid.tsx` no longer imports `FormField` on today's `main`. Its test's bare `{ name: 'title' }` literal is now a typed `FormField` value. - **`@object-ui/components`** `form.tsx`: the field's raw metadata carrier is typed `Record<string, unknown> | FormField` (the stashed object field, else the field config itself, which is what the code already did) on `RenderFieldProps.field`, `nativeNumberStep` and `dependentLookupParents`, instead of converting the config through the signature. - **`@object-ui/plugin-form`**: `group`, which the host copies from the object field and `deriveFieldGroupSections` reads back, is typed on a package-local `FormFieldWithGroup` (`fieldGroups.ts`). `ObjectForm`'s generated field and `buildFlatFields` write it through that type. The section-divider row's `collapsible` / `collapsed` / `onToggle` are typed on a module-local `SectionDividerRow`, which replaces a `Record<string, unknown>` row cast to `FormField` and an `as unknown as FormField` gate row. `SectionDividerSource.visibleWhen` is `FormField['visibleWhen']`, not `unknown`. **The immutable follow-up** (the work-item-4 landing record 6448239783: "`immutable` is declared on the mirror but not rendered by objectui (declared ≠ enforced). Render it, or record a refusal."). It is rendered. `normalizeSectionField` writes a form-view field's `immutable: true` as `disabled` when the form edits an existing record (`isCreateFormMode` false, the same create-mode question `required` asks there). A create form leaves it editable. The old pass-through copy to a runtime `immutable` key that no renderer read is gone. The `zod-mirror-parity` census row moves from `carried, not rendered` to `translated`. **Spec accept sets:** none moves. The diff touches no `packages/spec` path and no `*.zod.ts`. The objectui zod faces are byte-identical, so `objectui validate` and the strict face judge every document as before. Only TypeScript consumers that wrote an undeclared key on a fresh `FormField` literal see a change, from their own compiler. ## Tests - New `ui/packages/types/src/__tests__/form-field-closed-face-25923.test.ts`: fresh-literal `@ts-expect-error` rows for a misspelled key (`labell`) and a host-written key (`group`), plus the declared spelling compiling. Reverse check: with the signature put back, `tsc -p tsconfig.test.json` reds both rows with TS2578 (unused directive); restored from `HEAD`, empty `git diff HEAD`. - `sectionFields.spec-parity.test.ts` `immutable` row: an edit form (`mode: 'edit'`, `recordId`) draws it disabled with no `immutable` key carried; a create form leaves it enabled. - `flatFields.test.ts` parity pin spreads both producers into plain records instead of casting a `FormField` to `Record<string, unknown>`. ## Acceptance notes - `keyField`, `disclosure` and `precision` are still copied by `normalizeSectionField` onto the runtime field through its `any`-typed base, with no reader (the census rows say so). They are not on `FormField` and this PR does not put them there, since declaring keys no renderer reads would advertise them. The copy is a deliberate ADR-0007 pass-through in the mapper's own comment. Whether to keep carrying them or drop the copy is left to the seat. - The section-divider branch of `form.tsx` reads its row through `fieldProps as any`, as before. This PR types the producer side only. ## Verification <details><summary>ui type-check, all dependents of @object-ui/types (head 43cf87a)</summary> `bash scripts/pm/os-verify-lock.sh -c "cd ui && pnpm exec turbo run type-check --filter='...@object-ui/types' --concurrency=2 --continue"` gave `Tasks: 105 successful, 105 total` and `VERDICT command-exit 0`. The first measurement, with only the signature dropped, failed in `@object-ui/components` (`form.tsx` 824, 1946, 2021, 2117, 2257, 3359, 3824), then `@object-ui/plugin-form` (`fieldGroups.ts` 369) once components was fixed. The new pin's own `tsc -p tsconfig.test.json` in `@object-ui/types` exited 0 on the next head. </details> <details><summary>ui vitest (head 6692b1a)</summary> `pnpm exec vitest run --maxWorkers=2 packages/plugin-form/ packages/core/src/validation/ packages/components/src/renderers/form/ packages/types/src/__tests__/form-field-closed-face-25923.test.ts packages/types/src/__tests__/zod-mirror-parity.test.ts` under the lock gave `Test Files 310 passed (310)`, `Tests 3186 passed | 18 skipped (3204)`, `VERDICT command-exit 0`. `pnpm --dir ui exec vitest run scripts/` gave `169 passed | 2 skipped`, `4901 passed | 2 skipped`, exit 0. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands, all exit 0 (codes captured before any pipe). The changeset gates were re-run after the last changeset edit. `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. `check-adr-0087-registration`: `not-required (runtime-interface-only)`, verified on both interfaces. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxnmxDxesWCZGKX6jW)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1edceb1 commit a260556

13 files changed

Lines changed: 127 additions & 27 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/types': patch
3+
'@object-ui/core': patch
4+
'@object-ui/components': patch
5+
'@object-ui/plugin-form': patch
6+
---
7+
8+
fix(types,core,components,plugin-form)!: `FormField` drops its open index signature, and a form-view field's `immutable` locks the control once the record exists
9+
10+
Clause-②: yes (narrowing)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/types/src/form.ts#FormField, ui/packages/core/src/validation/server-owned-value.ts#FieldWithDeclaredDefault) Two published TypeScript interfaces each lose their open index signature and nothing else: no member is removed or renamed, no metadata key, accepted value, export or error code moves, the zod mirror `FormFieldSchema` and the strict authoring face are unchanged, and `objectstack migrate meta` has nothing to rewrite. The affected party is a TypeScript consumer, reached by its own compiler (excess-property error naming the undeclared key). -->
14+
15+
**BREAKING** type-surface narrowing, compile-time only.
16+
17+
- **`@object-ui/types`.** `FormField` no longer ends in `[key: string]: any`. A key no member declares — a misspelling included — is now a compile error on a fresh `FormField` literal, as it already was on every node type since `BaseSchema` closed. The declared members are unchanged, and so are the zod mirror `FormFieldSchema` and the strict face `StrictAnyComponentSchema`, which already refused undeclared keys.
18+
- **`@object-ui/core`.** `FieldWithDeclaredDefault`, the parameter type of `isServerOwnedValue`, drops its `[key: string]: unknown` signature, so a `FormField` value is assignable to it. It still reads only `field.defaultValue`.
19+
- **`@object-ui/components`.** The form renderer types the field's raw metadata carrier as `Record<string, unknown> | FormField` (the stashed object field, else the field config itself), instead of reading the config through the signature.
20+
- **`@object-ui/plugin-form`.** The host-written `group` key (copied from the object field for the field-group fallback) and the section-divider disclosure keys (`collapsible`, `collapsed`, `onToggle`) are typed on package-local hand-off types instead of riding the signature.
21+
22+
**Fix (`@object-ui/plugin-form`).** A form-view field declaring `immutable: true` (spec `FormField.immutable`, "editable on create, locked once the record exists") is now drawn disabled when the form edits an existing record, and stays editable on a create form. It used to be copied onto the runtime field as `immutable`, which no renderer reads, so an edit form left the control editable.
23+
24+
What to do: nothing if your `FormField` literals use only declared keys. Otherwise the compiler names each undeclared key on the literal that carries it (an excess-property error): correct a misspelling to the declared member, or, for a key only your host writes and reads, type it on a local intersection such as `FormField & { group?: string }` where its producer and reader live.

‎ui/packages/components/src/renderers/form/form.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ function dependentLookupParents(f: FormFieldConfig | undefined): string[] {
821821
if (typeof widget !== 'string' || !EXPANDABLE_FIELD_TYPES.has(normalizeFieldType(widget))) {
822822
return [];
823823
}
824-
const carrier: Record<string, unknown> = f.field || (f as Record<string, unknown>);
824+
const carrier: Record<string, unknown> | FormFieldConfig = f.field || f;
825825
const inner = carrier.field;
826826
const meta =
827827
inner !== null && typeof inner === 'object' && ('reference' in inner || 'type' in inner)
@@ -4369,7 +4369,7 @@ interface RenderFieldProps {
43694369
/** react-hook-form's ref binding, handed to the built-in control by name. */
43704370
ref?: RefCallBack;
43714371
/** The field's raw metadata object (`field.field`, else the field config). */
4372-
field?: Record<string, unknown>;
4372+
field?: Record<string, unknown> | FormFieldConfig;
43734373
/** A dependent select's gate hint while its controlling field is unset (#2284). */
43744374
emptyHint?: string;
43754375
[key: string]: unknown;
@@ -4729,7 +4729,7 @@ const BuiltinSelectControl = React.forwardRef<HTMLButtonElement, BuiltinSelectCo
47294729
* wrote it until objectui#12130; `objectFormNumericStep-9574.test.tsx` pins
47304730
* both of its routes.
47314731
*/
4732-
function nativeNumberStep(carrier: Record<string, unknown> | undefined): number | 'any' {
4732+
function nativeNumberStep(carrier: RenderFieldProps['field']): number | 'any' {
47334733
const step = carrier?.step;
47344734
if (typeof step === 'number') return step;
47354735
const scale = carrier?.scale;

‎ui/packages/core/src/validation/__tests__/server-owned-value.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import { describe, it, expect } from 'vitest';
2222
import { DEFAULT_VALUE_TOKENS } from '@objectstack/spec/data';
23+
import type { FormField } from '@object-ui/types';
2324
import { isRuntimeDefault, isServerOwnedValue } from '../server-owned-value.js';
2425

2526
/** A CEL Expression envelope — the non-token half of "the server resolves it". */
@@ -71,7 +72,8 @@ describe('isServerOwnedValue (#4085)', () => {
7172

7273
it('a field declaring no default is never server-owned', () => {
7374
expect(isServerOwnedValue(withDefault(undefined), true)).toBe(false);
74-
expect(isServerOwnedValue({ name: 'title' }, true)).toBe(false);
75+
const declaresNoStash: FormField = { name: 'title' };
76+
expect(isServerOwnedValue(declaresNoStash, true)).toBe(false);
7577
});
7678

7779
it('a hand-authored FormField carries no object metadata, so it is untouched', () => {

‎ui/packages/core/src/validation/server-owned-value.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,16 @@ export function isRuntimeDefault(v: unknown): boolean {
8585

8686
/**
8787
* A runtime `FormField` as far as this module is concerned: the resolved
88-
* object-field metadata stash, and whatever else the field carries.
88+
* object-field metadata stash.
8989
*
9090
* Stated structurally rather than as `@object-ui/types`' `FormField` so the
91-
* one key this module reads is visible in the signature — and with an index
92-
* signature so a caller (or a test) may hand over an object literal carrying
93-
* the rest of the field without tripping TypeScript's excess-property check.
91+
* one key this module reads is visible in the signature. No index signature:
92+
* `FormField` declares its keys and carries none (objectstack#25923), and an
93+
* interface without one is not assignable to a type that has one — a
94+
* `FormField` value is accepted here because it declares this `field` slot.
9495
*/
9596
export interface FieldWithDeclaredDefault {
9697
field?: { defaultValue?: unknown } | null;
97-
[key: string]: unknown;
9898
}
9999

100100
/**

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
filterSystemFields,
4646
sectionedGridLayout,
4747
} from './autoLayout';
48-
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
48+
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse, type FormFieldWithGroup } from './fieldGroups';
4949
import { mergeCustomFields } from './customFieldsMerge';
5050
import { hasSectionGroupReference, resolveSectionGroupReferences } from './sectionGroups';
5151
import {
@@ -958,7 +958,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
958958
const field = objectSchema.fields?.[name];
959959
if (field) {
960960
// Auto-generate field from schema
961-
const formField: FormField = {
961+
const formField: FormFieldWithGroup = {
962962
name: name,
963963
label: fieldLabel(schema.objectName, name, field.label || fieldName),
964964
// (type, multiple) decides the widget, not the type alone: a `select`

‎ui/packages/plugin-form/src/fieldGroups.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@
2121
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
2222
import type { FormField, ObjectFormSection } from '@object-ui/types';
2323

24+
/**
25+
* A runtime `FormField` carrying its object field's group membership.
26+
*
27+
* `group` is written by the HOST, never authored on a form field: the
28+
* object-bound form copies it from the object field (`Field.group`, the key of
29+
* one of the object's `fieldGroups`) and {@link deriveFieldGroupSections} reads
30+
* it back. So it is typed here, where its producer and consumer live, and not
31+
* on the `FormField` authoring face (ui AGENTS.md #0.1, objectstack#25923).
32+
*/
33+
export type FormFieldWithGroup = FormField & { group?: string };
34+
35+
/**
36+
* A `section-divider` row as {@link projectSectionDivider} writes it: a
37+
* runtime `FormField` plus the disclosure control the HOST resolves for it
38+
* (`resolveSectionCollapse`). The form renderer's divider branch reads the
39+
* three keys off the row; none of them is authored on a form field, so they
40+
* stay off the `FormField` authoring face (ui AGENTS.md #0.1, objectstack#25923).
41+
*/
42+
type SectionDividerRow = FormField & {
43+
collapsible?: boolean;
44+
collapsed?: boolean;
45+
onToggle?: () => void;
46+
};
47+
2448
/**
2549
* Derive form sections from an object's declared `fieldGroups` and each
2650
* rendered field's `group`.
@@ -49,7 +73,7 @@ export function deriveFieldGroupSections(
4973
// Pseudo-def over the rendered fields: membership is the only input the
5074
// derivation needs per field.
5175
fields: Object.fromEntries(
52-
fields.map((f) => [f.name, { group: (f as { group?: unknown }).group }]),
76+
fields.map((f) => [f.name, { group: (f as FormFieldWithGroup).group }]),
5377
),
5478
});
5579
if (!derived) return null;
@@ -135,7 +159,7 @@ export interface SectionDividerSource {
135159
/** The authored blurb (spec `FormSection.description`). */
136160
description?: string;
137161
/** ADR-0089 `FormSection.visibleWhen` (#6111), unevaluated. */
138-
visibleWhen?: unknown;
162+
visibleWhen?: FormField['visibleWhen'];
139163
/**
140164
* The fault direction of {@link visibleWhen}, copied onto the row unchanged
141165
* (`ObjectFormSection.visibleWhenFault`, objectui#13055).
@@ -335,11 +359,11 @@ export function projectSectionDivider(source: SectionDividerSource): FormField[]
335359
...(visibleWhenFault !== undefined ? { visibleWhenFault } : {}),
336360
fields: members,
337361
colSpan: DIVIDER_COL_SPAN,
338-
} as unknown as FormField,
362+
} satisfies SectionDividerRow,
339363
];
340364
}
341365

342-
const row: Record<string, unknown> = {
366+
const row: SectionDividerRow = {
343367
name: `__section_${key}`,
344368
label: title,
345369
type: 'section-divider',
@@ -355,5 +379,5 @@ export function projectSectionDivider(source: SectionDividerSource): FormField[]
355379
row.collapsed = collapse.collapsed;
356380
row.onToggle = collapse.onToggle;
357381
}
358-
return [row as FormField];
382+
return [row];
359383
}

‎ui/packages/plugin-form/src/flatFields.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,10 @@ describe('buildFlatFields WARNS on an unresolvable top-level member (route 1, ob
135135
describe('buildFlatFields ↔ normalizeSectionField parity (#4755)', () => {
136136
it('agrees key-by-key with the sectioned producer, adding only `group`', () => {
137137
for (const name of Object.keys(OBJECT_SCHEMA.fields)) {
138-
const flat = buildFlatFields({ ...ctx, fields: [name] })[0] as Record<string, unknown>;
139-
const sectioned = normalizeSectionField(name, ctx) as Record<string, unknown>;
138+
// Spread into plain records: the pin enumerates whatever keys each
139+
// producer wrote, declared on `FormField` or not.
140+
const flat: Record<string, unknown> = { ...buildFlatFields({ ...ctx, fields: [name] })[0] };
141+
const sectioned: Record<string, unknown> = { ...normalizeSectionField(name, ctx) };
140142

141143
expect(new Set(Object.keys(flat))).toEqual(new Set([...Object.keys(sectioned), 'group']));
142144
for (const key of Object.keys(sectioned)) {

‎ui/packages/plugin-form/src/flatFields.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import type { FormField } from '@object-ui/types';
3737
import { fromObjectSchema, warnUnresolvedTopLevelField, type SectionFieldsContext } from './sectionFields';
3838
import { mergeCustomFields } from './customFieldsMerge';
39+
import type { FormFieldWithGroup } from './fieldGroups';
3940

4041
export interface FlatFieldsContext extends SectionFieldsContext {
4142
/**
@@ -92,6 +93,6 @@ export function buildFlatFields(ctx: FlatFieldsContext): FormField[] {
9293
// it the moment explicit sections exist, so stamping `group` onto
9394
// section-built fields would add a key with no reader.
9495
group: (field as any).group,
95-
};
96+
} satisfies FormFieldWithGroup;
9697
});
9798
}

‎ui/packages/plugin-form/src/sectionFields.spec-parity.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,14 @@ const TABLE: Record<string, (f: (def: Record<string, unknown>) => any) => void>
106106
placeholder: (f) => expect(f({ placeholder: 'Type…' }).placeholder).toBe('Type…'),
107107
helpText: (f) => expect(f({ helpText: 'Hint' }).description).toBe('Hint'),
108108
readonly: (f) => expect(f({ readonly: true }).disabled).toBe(true),
109-
immutable: (f) => expect(f({ immutable: true }).immutable).toBe(true),
109+
immutable: (f) => {
110+
// Locked once the record exists: an edit form draws it disabled, a create
111+
// form leaves it editable, and no `immutable` key is carried (objectstack#25923).
112+
const edit = normalizeSectionField({ field: 'ghost', immutable: true }, { ...ctx, mode: 'edit', recordId: 'r1' }) as any;
113+
expect(edit.disabled).toBe(true);
114+
expect(edit.immutable).toBeUndefined();
115+
expect(f({ immutable: true }).disabled).toBeFalsy();
116+
},
110117
required: (f) => expect(f({ required: true }).required).toBe(true),
111118
hidden: (f) => expect(f({ hidden: true }).hidden).toBe(true),
112119
colSpan: (f) => expect(f({ colSpan: 2 }).colSpan).toBe(2),

‎ui/packages/plugin-form/src/sectionFields.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,14 @@ function resolveSectionEntry(
414414
);
415415
}
416416
if (fd.readonly != null) base.disabled = fd.readonly || base.disabled;
417-
if (fd.immutable != null) base.immutable = fd.immutable;
417+
// `immutable`: editable on create, locked once the record exists (spec
418+
// FormField.immutable). It lands on `disabled`, the slot `readonly` above
419+
// locks a control through, and asks the create-mode question `required`
420+
// asks (`isCreateFormMode`), so a form seeded as create never locks it and an
421+
// edit form always does. It used to be copied through as `immutable`, a key
422+
// no renderer reads, so an edit form left the control editable
423+
// (objectstack#25923).
424+
if (fd.immutable === true && !isCreateFormMode(ctx)) base.disabled = true;
418425
if (fd.hidden != null) base.hidden = fd.hidden;
419426
if (fd.colSpan != null) base.colSpan = fd.colSpan;
420427
if (fd.span != null) base.span = fd.span;

0 commit comments

Comments
 (0)