From 91dc280f2ec25ed8a34c4070f9a1fc46ea6b2b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:34:33 +0000 Subject: [PATCH 1/6] fix(app-shell): SchemaForm resolves a { group } form section through the shared resolver (objectui#8725) Part 1: FormSectionSpec.fields becomes optional, matching @objectstack/spec's FormSection, where `group` is the other way to declare a section's members. Arm B: every read of a section's `fields` in SchemaForm now sees a resolved section. A { group } section goes through @object-ui/plugin-form's resolveSectionGroupReferences (the resolver ObjectForm and the console FormPage already use) before the pre-flight loop that used to throw "s.fields is not iterable". SchemaForm hosts carry no object definition, so it is called with objectDef null and resolvable false: a group resolves empty and the dangling key is reported, which is the resolver's defined answer. A section declaring neither `fields` nor `group` still throws, now a TypeError naming it. The measurement pins of PR #8739 went red with this change and are rewritten in the same commit onto the ruled behaviour; the two type-level collaterals the widening surfaced (PIN D and mergeServerFields.test.ts) read the optional key through NonNullable and a non-null assertion. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...orm.groupSectionReachability-8725.test.tsx | 378 +++++++++--------- .../src/views/metadata-admin/SchemaForm.tsx | 96 ++++- .../form-spec.containers.test.tsx | 5 +- .../src/views/metadata-admin/form-spec.ts | 21 +- .../metadata-admin/mergeServerFields.test.ts | 11 +- 5 files changed, 300 insertions(+), 211 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx index 8906263d70..834712ae27 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx @@ -1,141 +1,83 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * REACHABILITY MEASUREMENT for objectui#8725 — a `{ group }` form section and - * `SchemaForm`'s three unguarded `s.fields` reads. + * objectui#8725 — a `{ group }` form section reaching `SchemaForm`. * - * ⚠️ This file records a DEFECT that is still open. Its runtime pins assert what - * this renderer does TODAY, which is to die. They are a measurement, not an - * endorsement: whoever rules on the disposition (see "What is NOT decided here") - * is expected to turn them into pins on the chosen behaviour, and their going - * red is the intended signal that the ruling landed. + * ## What this file pinned before, and why those pins were rewritten * - * ## Why the measurement was needed + * This file first landed as a MEASUREMENT (PR #8739): its runtime pins asserted + * that a spec-legal `{ group }` section, arriving on the untyped `/meta/types` + * server document, made `SchemaForm` throw `TypeError: s.fields is not + * iterable` out of the component body — from the pre-flight `for…of` in + * `SchemaFormBody`, upstream of both `.map` sites the card named — and took every + * well-formed sibling section with it, on the simple and the tabbed arm alike. + * Its compile-time pins asserted that `FormSectionSpec` refused the spec-legal + * shape (a barrier on the TypeScript channel only) and that + * `RichMetadataTypeEntry.form` admitted it untyped (the channel that did carry + * it). Those pins were meant to go red when the ruling landed, and they did. * - * objectui#8725 was filed with reachability explicitly NOT measured, and named - * that as its own gap: "a defect established only by reading is the thing this - * queue has been wrong about before". The card guessed the inputs were "this - * repo's own `*.form.ts` create/edit schemas, which do not author `group`", - * which would have made the defect latent. + * ## The ruling they now pin (PM ruling on objectui#8725) * - * That guess is FALSE, in a way that matters. This repository contains no - * `*.form.ts` file at all. The `form` document `SchemaForm` renders arrives on - * two channels, and the wider of the two is a SERVER document: + * - `FormSectionSpec.fields` is OPTIONAL, matching `@objectstack/spec`. + * - Every read of a section's `fields` in `SchemaForm` sees a RESOLVED section: + * a `{ group }` section goes through `@object-ui/plugin-form`'s + * `resolveSectionGroupReferences`, the one resolver `ObjectForm` and + * `apps/console`'s `FormPage` already use, so this renderer gives THEIR answer + * — including for a group that names no declared field group. + * - ⛔ Not `?? []`. What the old crash pins protected survives the rewrite: a + * malformed section is a throw or a report, never a silent drop. + * - `RichMetadataTypeEntry.form` is `FormViewSpec`, not an untyped record. * - * • `RichMetadataTypeEntry.form` (`useMetadata.ts`) is `Record` — the `/meta/types` registry response, deserialised with no - * validation and no normalisation (`useMetadataTypes` casts the payload) — - * and `ResourceEditPage.tsx` hands it to this component as - * `form={... (entry?.form as any)}`. `EmbeddedItemEditor.tsx` does the same - * with `subEntry?.form as any`. - * • the spec-bundled forms (`getPageForm` / `getViewForm` / `getReportForm` / - * `getDashboardForm`), which read `pageForm` & co. straight out of - * `@objectstack/spec/ui` — an upstream, versioned document this repo does - * not author either. Measured against the installed artifact (17.4.0), none - * of those four declares a `group` section today; that is a property of one - * spec release, not a barrier. + * ## ⚠️ What "the resolver's answer" is HERE, measured rather than assumed * - * So the REQUIRED `fields` on {@link FormSectionSpec} stands between a - * TypeScript author and this renderer, and between nothing else. A section - * carrying `group` and no `fields` — which `FormSectionSchema` ACCEPTS, measured - * against the installed artifact (17.4.0) — reaches the reads unimpeded. - * - * ## What was measured, and how the card's wording is one word off - * - * The card names three read sites and predicts `Cannot read properties of - * undefined (reading 'map')`. That is the diagnostic of the SECOND and THIRD - * sites. It is not what fires. `SchemaFormBody`'s own pre-flight loop is above - * both of them in the same render, and it is a `for…of`, so the throw is a - * TypeError naming iteration, not `.map` — see {@link readSiteThatFiresFirst}. - * The distinction is load-bearing for whoever fixes this: guarding only the two - * `.map` sites the card quotes leaves the crash exactly where it is. - * - * ## What is NOT decided here (objectui#8725's own framing) - * - * Neither half of the fix is in this file. Widening `FormSectionSpec.fields` to - * optional ALONE removes the compile-time barrier above without deciding what - * the reads do, which converts a `tsc` error into this same runtime death on a - * second channel; and choosing what a group-referencing section renders is a - * behaviour ruling on a renderer (`?? []` degrades it to an EMPTY section — - * today's silent drop made non-throwing) or an import of - * `resolveSectionGroupReferences` from `@object-ui/plugin-form`, which needs an - * object definition this component is not handed. ⛔ And no assembly rule may be - * re-implemented on the objectui side (objectui#7051, objectstack#13855). - * - * ## One thing the card could not know, measured here - * - * The trap the card names — "widening the type alone removes the compile-time - * barrier" — is real but LOUD, not silent. Measured by widening - * `FormSectionSpec.fields` to optional against this package's `type-check`: - * `strictNullChecks` immediately reports `TS18048: 's.fields' is possibly - * 'undefined'` at all three read sites (`SchemaForm.tsx` 786 / 1030 / 1147), - * plus collateral at `form-spec.containers.test.tsx` PIN D (whose - * `FormSectionSpec['fields'][number]` then needs a `NonNullable`) and four - * sites in `mergeServerFields.test.ts`. So part 1 cannot ship without an author - * confronting every read. What the compiler cannot do is stop them from typing - * `?? []` to silence it — which is the behaviour ruling this card is holding - * open, and the reason this file stops here. + * A `group` resolves against the BOUND OBJECT's `fieldGroups`. `SchemaForm` + * renders metadata-type editors (`data: { provider: 'schema', schemaId }`), the + * `/meta/types` entry carries a `schema` and a `form` but no field groups, and + * no host hands this component an object definition. So every `{ group }` + * section here is one that "names no declared field group", and the resolver's + * defined answer for that — measured, and identical in `FormPage` for the same + * section over an object that does not declare the group — is an EMPTY section + * plus a `console.error` naming the group. The pins below assert exactly that, + * by calling the resolver with `SchemaForm`'s own inputs rather than by + * restating its output, so a change to the resolver moves this file with it. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; +import { resolveSectionGroupReferences } from '@object-ui/plugin-form'; import { SchemaForm } from './SchemaForm'; import type { FormSectionSpec, FormViewSpec } from './form-spec'; import type { RichMetadataTypeEntry } from './useMetadata'; -afterEach(cleanup); - type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false; /** * Compile-time half. Erased at runtime, so vitest proves nothing about these; * `tsc -p packages/app-shell/tsconfig.test.json` (chained from this package's * `type-check` script) is what judges them. */ -export function groupSectionReachabilityPins(): void { - // ── PIN T1 — the BARRIER, as it stands today. `FormSectionSpec` re-declares - // `fields` as REQUIRED while `FormSectionSchema` accepts `{ group }` with no - // `fields` at all, so the spec-legal shape does not compile — inside a type - // whose own header says it "describes what an AUTHOR WROTE, so it stays as - // wide as the document". - // - // ⛔ This is a TWO-WAY pin and that is the point. `@ts-expect-error` is itself - // an error (TS2578) the moment the line below it starts compiling, so nobody - // can widen `fields` without this file going red and putting the runtime pins - // below in front of them. objectui#8725's trap is precisely that the widening - // reads like a one-character type fix while it REMOVES the only thing that - // currently keeps a TypeScript author out of the crash measured below. - // - // Whoever lands the widening TOGETHER with a disposition for the read sites - // should DELETE this pin — it has no value once the reads are safe. - // @ts-expect-error objectui#8725 — `fields` is REQUIRED here; the spec-legal - // `{ group }` section is refused by the type. Widening it alone is the trap. - const specLegalSectionIsRefused: FormSectionSpec = { group: 'contact_info' }; - void specLegalSectionIsRefused; +export function groupSectionTypePins(): void { + // ── PIN T1 — the spec-legal `{ group }` section COMPILES as a + // `FormSectionSpec`. Before objectui#8725 this line was a `@ts-expect-error`: + // `fields` was re-declared REQUIRED inside a type whose own header says it + // "describes what an AUTHOR WROTE, so it stays as wide as the document". + const specLegalGroupSection: FormSectionSpec = { group: 'contact_info' }; + void specLegalGroupSection; + const fieldsIsOptional: Assert = true; + void fieldsIsOptional; - // Positive control for PIN T1: `group` itself IS a declared key of this type - // (it is derived from the spec, and no `Omit` names it), so the refusal above - // is about the MISSING `fields` and not about an unknown `group`. Without this - // line, a type that had never heard of `group` would satisfy PIN T1 for the - // wrong reason. - const groupIsADeclaredKey: FormSectionSpec = { group: 'contact_info', fields: [] }; - void groupIsADeclaredKey; - const groupIsOnTheSpecDerivedHalf: Assert<'group' extends keyof FormSectionSpec ? true : false> = - true; - void groupIsOnTheSpecDerivedHalf; + // Negative control for PIN T1: this program really judges this file, and + // `group` is a typed key rather than an index signature that admits anything. + // @ts-expect-error objectui#8725 — `group` is a field-group KEY (a string). + const groupIsTyped: FormSectionSpec = { group: 42 }; + void groupIsTyped; - // ── PIN T2 — the barrier guards ONE channel, and it is not the one that - // reaches the renderer. The registry entry's `form` is an untyped server - // document, so the crashing shape is assignable to it. - // - // OBSERVED RED, so this is a measurement and not a tautology: retyping - // `RichMetadataTypeEntry.form` to `FormViewSpec` turns this line into - // `TS2322: Type '{ label: string; group: string; }' is not assignable to type - // 'FormSectionSpec'` — and, measured, into the ONLY error in the package. - // That is the change which would put PIN T1's barrier on the path - // `ResourceEditPage` actually renders (its `form={entry?.form as any}` cast - // would have to go with it). Until then the `as any` at that call site is not - // a lint smell, it is the whole reachability result. + // ── PIN T2 — the registry entry's `form` is still an untyped server document + // (arm C, the retype to `FormViewSpec`, lands after parts 1 and 2), so the + // crash document of the original measurement is assignable to it. const serverRegistryFormAdmitsIt: NonNullable = { type: 'simple', sections: [{ label: 'Contact', group: 'contact_info' }], @@ -148,125 +90,167 @@ const schema = { properties: { a: { type: 'string', title: 'Field A' }, b: { type: 'string', title: 'Field B' }, + c: { type: 'string', title: 'Field C' }, }, }; +/** The metadata type the form edits — what the resolver's diagnostic names. */ +const SCHEMA_ID = 'demo_type_8725'; + /** - * The crashing document, spelled the way it ARRIVES: a plain deserialised - * object, not a `FormViewSpec` literal. `as never` at the prop is this file's - * stand-in for `ResourceEditPage`'s `as any` — the cast is part of the - * measurement, not a convenience. + * A form document spelled the way it ARRIVES from `/meta/types`: plain data + * carrying the `data` binding `defineForm` writes onto every metadata form. */ -const serverFormWithGroupSection = { - type: 'simple', - sections: [{ label: 'Contact', group: 'contact_info' }], -}; +function serverForm(sections: unknown[], type = 'simple'): FormViewSpec { + return { type, data: { provider: 'schema', schemaId: SCHEMA_ID }, sections } as unknown as FormViewSpec; +} -function renderCatching(form: unknown): { error: Error | undefined; html: string } { +function renderForm(form: FormViewSpec): { error: Error | undefined; html: string; ids: string[] } { let error: Error | undefined; let html = ''; + let ids: string[] = []; try { const { container } = render( - {}} />, + {}} />, ); html = container.innerHTML; + ids = Array.from(container.querySelectorAll('input[id^="mdf-"]')).map((el) => el.id); } catch (err) { error = err as Error; } - return { error, html }; + return { error, html, ids }; } -describe('objectui#8725 — a `{ group }` section reaching SchemaForm', () => { - // ── PIN R1 — CONTROL, and the non-regression leg. A section that authors - // `fields` renders them. Everything below is a claim about the ABSENCE of - // `fields`; without this line a renderer that drew nothing at all would - // satisfy the whole file. This is the pin that reddens under the caricature - // "every section resolves to no fields". - it('CONTROL: a section that authors `fields` still renders them', () => { +/** + * The field names the shared resolver assigns to each section, given exactly + * the inputs `SchemaForm` hands it — the answer `FormPage` gives for the same + * section over an object that does not declare the group. + */ +function resolverFieldNames(form: FormViewSpec): string[][] { + const resolved = resolveSectionGroupReferences( + form.sections as unknown as Parameters[0], + { objectName: SCHEMA_ID, formType: form.type, objectDef: null, resolvable: false }, + ) ?? []; + return resolved.map((s) => (s.fields ?? []).map((f) => (typeof f === 'string' ? f : f.name))); +} + +let consoleError: ReturnType; +beforeEach(() => { + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('objectui#8725 — a `{ group }` section in SchemaForm resolves through the shared resolver', () => { + // ── CONTROL, and the non-regression leg. A section that enumerates `fields` + // renders them exactly as before. Without it, a renderer that drew nothing at + // all would satisfy every group leg below. + it('CONTROL: a section that enumerates `fields` still renders them', () => { const form: FormViewSpec = { type: 'simple', sections: [{ label: 'Basics', fields: [{ field: 'a' }] }], }; - render( {}} />); + render( {}} />); expect(screen.getByText('Field A')).toBeInTheDocument(); expect(screen.getByText('Basics')).toBeInTheDocument(); }); - // ── PIN R2 — REACHED, and it throws OUT OF THE COMPONENT BODY. Not "renders - // an empty section", not "renders a bordered card with a heading": `render()` - // itself throws, so there is no partial tree to inspect and no per-section - // boundary could have contained it. This is the shape that blanked - // `SimpleObjectForm` before PR #8644. - it('MEASURED TODAY (defect): the render throws instead of degrading', () => { - const { error, html } = renderCatching(serverFormWithGroupSection); - expect(error).toBeInstanceOf(TypeError); - // The envelope, not just "it threw": the message is one of the two - // diagnostics the unguarded `s.fields` reads produce, which is what - // identifies this as that family and not some unrelated failure in the - // render path. WHICH of the two fires is PIN R3's business, deliberately — - // so a half-fix moves R3 and R5 without disturbing this line. - expect(error?.message).toMatch(/\bfields\b|reading ['"]map['"]/); - // Nothing rendered. The card's parent measurement (objectui#8641) found a - // VISIBLE empty bordered card for its defect; this one leaves no DOM at all. - expect(html).toBe(''); + // ── The first read no longer throws, and a well-formed sibling survives. This + // is the old "the render throws" and "a sibling is destroyed with it" pair, + // inverted: the field rows on screen are the resolver's, in its order. + it('renders the resolver\'s fields, in its order, with the well-formed siblings intact', () => { + const form = serverForm([ + { label: 'Basics', fields: ['a'] }, + { group: 'contact_info_8725_order' }, + { label: 'More', fields: ['c', 'b'] }, + ]); + const { error, ids } = renderForm(form); + + expect(error).toBeUndefined(); + expect(ids).toEqual(resolverFieldNames(form).flat().map((n) => `mdf-${n}`)); + // Stated literally as well, so the leg above cannot pass by both sides + // agreeing on nothing. + expect(ids).toEqual(['mdf-a', 'mdf-c', 'mdf-b']); }); - // ── PIN R3 — WHICH read site fires, and the correction to the card's wording. - // - // objectui#8725 quotes `Cannot read properties of undefined (reading 'map')` - // and names `SchemaForm.tsx:786` / `:1030` / `:1147`. `:786` is - // `for (const f of s.fields)` inside `SchemaFormBody`'s pre-flight - // "does the layout name any field this schema has" loop, and it runs BEFORE - // `SectionedSchemaForm` is even constructed — so the `.map` diagnostic the - // card predicts is never the one a user sees. - // - // ⚠️ The exact V8 text observed here is `s.fields is not iterable`; the - // assertions below are written so that the DISCRIMINATION (iteration site vs - // `.map` site) does not rest on engine-specific wording. Guarding only the two - // `.map` sites turns this pin red while PIN R2 stays green, which is exactly - // the half-fix this pin exists to catch. - it('MEASURED TODAY (defect): the FIRST unguarded read is the `for…of`, not a `.map`', () => { - const { error } = renderCatching(serverFormWithGroupSection); - expect(error?.message).not.toMatch(/reading ['"]map['"]/); - expect(error?.message).toMatch(/not iterable/); + // ── The resolver's defined answer for a group that names no declared field + // group: the section is EMPTY and the dangling key is REPORTED. The report is + // what separates this from the fenced `?? []`, which renders the same empty + // section and says nothing. + it('a group naming no declared field group renders empty AND is reported, as the resolver defines', () => { + const form = serverForm([ + { label: 'Basics', fields: ['a'] }, + { group: 'no_such_group_8725' }, + ]); + const { error, ids } = renderForm(form); + + expect(error).toBeUndefined(); + expect(resolverFieldNames(form)[1]).toEqual([]); + expect(ids).toEqual(['mdf-a']); + + const said = consoleError.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(said).toContain('no_such_group_8725'); + expect(said).toContain(SCHEMA_ID); + expect(said).toContain('fieldGroups'); }); - // ── PIN R4 — one bad section takes the WHOLE form with it, including the - // sections that are perfectly well-formed. This is why the disposition is a - // ruling and not a nicety: the blast radius is the document, not the section. - it('MEASURED TODAY (defect): a well-formed sibling section is destroyed with it', () => { - const mixed = { - type: 'simple', - sections: [ + // ── The tabbed arm, which the measurement showed died at the same upstream + // read. Recorded so the fix is not scoped to one arm. + it('the tabbed arm resolves the same way and keeps its other tabs', () => { + const form = serverForm( + [ { label: 'Basics', fields: ['a'] }, - { label: 'Contact', group: 'contact_info' }, + { group: 'contact_info_8725_tabbed' }, + { label: 'More', fields: ['b'] }, ], - }; - const { error, html } = renderCatching(mixed); + 'tabbed', + ); + const { error } = renderForm(form); + + expect(error).toBeUndefined(); + expect(screen.getByRole('tab', { name: 'Basics' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'More' })).toBeInTheDocument(); + // The resolved group section has no rows, so it earns no tab. + expect(screen.getAllByRole('tab')).toHaveLength(2); + }); +}); + +describe('objectui#8725 — a section with NO member source is still refused out loud', () => { + // ── The rewritten crash pin. `FormSectionSchema` refuses a section carrying + // neither `fields` nor `group` ("A section must declare its members exactly + // one way"), and the resolver leaves it untouched because it is not a group + // reference. It stays a THROW out of the render — not an empty section — and + // the throw names the section it refuses. + it('neither `fields` nor `group`: the render throws a TypeError naming the section', () => { + const { error, html } = renderForm( + serverForm([ + { label: 'Basics', fields: ['a'] }, + { label: 'Contact' }, + ]), + ); + expect(error).toBeInstanceOf(TypeError); + expect(error?.message).toContain('"Contact"'); + expect(error?.message).toContain('`fields`'); + expect(error?.message).toContain('`group`'); expect(html).toBe(''); }); - // ── PIN R5 — the tabbed arm dies the same way, at the same site. `:1147` is - // inside the `isTabbed` branch, so a reader could reasonably expect the tabbed - // form to fail differently; it does not, because `:786` is upstream of both - // arms. Recorded so the fix is not scoped to one arm. - it('MEASURED TODAY (defect): the tabbed arm dies at the same upstream read', () => { - const tabbed = { - type: 'tabbed', - sections: [ - { label: 'Basics', fields: ['a'] }, - { label: 'Contact', group: 'contact_info' }, - ], - }; - const { error } = renderCatching(tabbed); + it('the tabbed arm refuses the same section the same way', () => { + const { error } = renderForm( + serverForm( + [ + { label: 'Basics', fields: ['a'] }, + { label: 'Contact' }, + ], + 'tabbed', + ), + ); + expect(error).toBeInstanceOf(TypeError); - expect(error?.message).toMatch(/not iterable/); + expect(error?.message).toContain('"Contact"'); }); }); - -/** - * Named export so the compile-time block above is not dead code to the linter, - * mirroring `form-spec.containers.test.tsx`'s `formContainerContractPins`. - */ -export const readSiteThatFiresFirst = 'SchemaForm.tsx SchemaFormBody — for (const f of s.fields)'; diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 8eb231acbe..0eb5270382 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -56,6 +56,7 @@ import { CollapsibleContent, } from '@object-ui/components'; import { usePredicateScope } from '@object-ui/react'; +import { resolveSectionGroupReferences } from '@object-ui/plugin-form'; import { evaluatePredicate, buildPredicateCtx, visibleOptions } from './predicate.js'; import type { FormFieldSpec, FormSectionSpec, FormViewSpec, VisibilityPredicate } from './form-spec.js'; import { @@ -1060,8 +1061,13 @@ function SchemaFormBody({ // total mismatch and fall through to the flat schema-driven // rendering so the user still gets a usable form. if (form?.sections?.length) { + // Every read below — this pre-flight included, which is the one that threw + // first — sees RESOLVED sections: a `{ group }` section has been through + // `resolveSectionGroupReferences` and a section declaring neither member + // source has been refused (objectui#8725). + const sections = resolveFormSections(form); const declaredFields: string[] = []; - for (const s of form.sections) { + for (const s of sections) { for (const f of s.fields) { declaredFields.push(typeof f === 'string' ? f : f.field); } @@ -1071,7 +1077,8 @@ function SchemaFormBody({ if (usable) { return ( }; + +function declaresMembers(s: FormSectionSpec): s is ResolvedFormSection { + return Array.isArray(s.fields); +} + +/** + * Resolve a form's sections into ones with a member list (objectui#8725). + * + * `@objectstack/spec` lets a section declare its members one of two ways: + * enumerate `fields`, or point `group` at a declared field group. A `{ group }` + * section reaches this renderer on the `/meta/types` server document, which is + * not validated on the way in, and before this function the first read of + * `s.fields` threw `TypeError: s.fields is not iterable` out of the component + * body, taking every well-formed sibling section with it. + * + * ⛔ No assembly rule is decided here. A `{ group }` section is resolved by + * `@object-ui/plugin-form`'s `resolveSectionGroupReferences`, the one resolver + * `ObjectForm` and `apps/console`'s `FormPage` already use, so this renderer + * gives the same answer they give, including for a group that names no + * declared field group: the section resolves EMPTY and the resolver reports the + * dangling key on `console.error`. + * + * `objectDef` is `null` and `resolvable` is `false`, and that is a measurement, + * not a default. A group resolves against the BOUND OBJECT's `fieldGroups`, and + * nothing this component renders has one: its forms are metadata-type editors + * (`data: { provider: 'schema', schemaId }`), the `/meta/types` entry carries a + * `schema` and a `form` but no field groups, and no host hands this component an + * object definition. `resolvable: false` is the resolver's own flag for "this + * form can never load an object definition", so a `{ group }` section is + * reported as unresolvable instead of being left to look like a load in flight. + * If a host ever does hold a real object definition, it is passed here. + * + * ⛔ Not `?? []`. A section that declares NEITHER `fields` nor `group` is + * refused by `FormSectionSchema` at parse ("A section must declare its members + * exactly one way"), and the resolver leaves it untouched because it is not a + * group reference. It is refused here too, out loud: rendering it as an empty + * section would turn a malformed document into a silent drop. + */ +function resolveFormSections(form: FormViewSpec): ResolvedFormSection[] { + const data = form.data; + const schemaId = data && 'schemaId' in data ? data.schemaId : undefined; + const resolved = (resolveSectionGroupReferences( + // Two packages describing ONE authored document, exactly as in `FormPage`: + // `FormSectionSpec` is this package's authoring type and `ObjectFormSection` + // the plugin's. The parameter type is taken FROM the published signature, + // so a change to it lands here as a compile error. + form.sections as unknown as Parameters[0], + { + objectName: schemaId ?? '', + formType: form.type, + objectDef: null, + resolvable: false, + }, + ) ?? []) as unknown as FormSectionSpec[]; + return resolved.map((s, index) => { + if (declaresMembers(s)) return s; + throw new TypeError( + `[SchemaForm] form section ${index}${s.label ? ` ("${s.label}")` : ''} declares neither ` + + '`fields` nor `group`. A section must declare its members exactly one way, and ' + + "`@objectstack/spec`'s FormSectionSchema refuses this shape at parse: enumerate " + + '`fields`, or point `group` at a declared field group.', + ); + }); +} + function SectionedSchemaForm({ - form, + sections: resolvedSections, + formType, props, required, hiddenFields, @@ -1278,7 +1358,9 @@ function SectionedSchemaForm({ idPath, onChange, }: { - form: FormViewSpec; + /** The form's sections, already through {@link resolveFormSections}. */ + sections: ResolvedFormSection[]; + formType: FormViewSpec['type']; props: Record; required: string[]; hiddenFields: string[]; @@ -1297,15 +1379,15 @@ function SectionedSchemaForm({ // returns below. `buildPredicateCtx` selects only the ADR-0068 identity roots // out of it and keeps `data` = the draft (objectui#6247). const hostScope = usePredicateScope(); - const sections = (form.sections ?? []).filter((s) => { + const sections = resolvedSections.filter((s) => { const visibility = readVisibility(s); return !visibility || evaluatePredicate(visibility, buildPredicateCtx(value, hostScope)); }); // Decide whether to render as tabs or stacked sections. - const isTabbed = form.type === 'tabbed' && sections.length > 1; + const isTabbed = formType === 'tabbed' && sections.length > 1; - const renderSection = (s: FormSectionSpec, idx: number) => { + const renderSection = (s: ResolvedFormSection, idx: number) => { const fields = s.fields .map(normaliseField) .filter((f) => { diff --git a/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx b/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx index d37269a044..b0ef7c0790 100644 --- a/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx @@ -114,7 +114,10 @@ export function formContainerContractPins(): void { // here: `no-restricted-imports` bans it from `@objectstack/spec/ui`, because // that name is also the runtime field contract in `@object-ui/types` // (objectui#3090). So the arm is reached through `FormSection` instead. - type SectionFieldArm = Exclude; + // `fields` is OPTIONAL since objectui#8725 (the spec-legal `{ group }` section + // carries none), so the element type is read through `NonNullable`, exactly as + // the spec arm below already is. + type SectionFieldArm = Exclude[number], string>; const fieldsAreTheLeaf: Assert> = true; void fieldsAreTheLeaf; diff --git a/packages/app-shell/src/views/metadata-admin/form-spec.ts b/packages/app-shell/src/views/metadata-admin/form-spec.ts index 3d4df17c38..da9f62ee39 100644 --- a/packages/app-shell/src/views/metadata-admin/form-spec.ts +++ b/packages/app-shell/src/views/metadata-admin/form-spec.ts @@ -269,8 +269,25 @@ export type FormSectionSpec = visibleWhen?: VisibilityPredicate; /** @deprecated ADR-0089 alias of `visibleWhen`; still read for legacy layouts. */ visibleOn?: VisibilityPredicate; - /** The authored field list. Element type is the converged leaf (objectui#5542). */ - fields: Array; + /** + * The authored field list. Element type is the converged leaf (objectui#5542). + * + * OPTIONAL, matching `@objectstack/spec`'s `FormSection.fields` + * (objectui#8725), and optional ONLY in the sense that `group` -- inherited + * from the spec half of this type -- is the other way to declare the same + * fact: `FormSectionSchema` refuses a section carrying neither and refuses + * one carrying both. Declaring it REQUIRED here refused the spec-legal + * `{ group }` section at compile time while doing nothing about the channel + * that actually delivers one (an untyped server document), so the shape + * reached `SchemaForm`'s reads anyway and threw out of the component body. + * + * ⛔ A reader never defaults this with `?? []`: that renders a group + * section as a silently empty one. `SchemaForm` resolves a `{ group }` + * section through `@object-ui/plugin-form`'s `resolveSectionGroupReferences` + * before any read, exactly as `apps/console`'s `FormPage` does, and still + * refuses out loud a section that declares neither key. + */ + fields?: Array; }; /** diff --git a/packages/app-shell/src/views/metadata-admin/mergeServerFields.test.ts b/packages/app-shell/src/views/metadata-admin/mergeServerFields.test.ts index 3f4f25e38c..7f09880410 100644 --- a/packages/app-shell/src/views/metadata-admin/mergeServerFields.test.ts +++ b/packages/app-shell/src/views/metadata-admin/mergeServerFields.test.ts @@ -66,7 +66,10 @@ describe('mergeServerFields', () => { expect(sections).toHaveLength(2); const added = sections[1]; expect(added.label).toBe('More fields'); - const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + // `fields` is optional on `FormSectionSpec` since objectui#8725. The merge + // always appends an enumerated section, so `!` throws if it ever stops doing + // so; `?? []` would instead make every `not.toContain` leg below vacuous. + const fieldNames = added.fields!.map((f) => (typeof f === 'string' ? f : f.field)); expect(fieldNames).toEqual(['dataset', 'rows', 'values', 'runtimeFilter']); }); @@ -79,7 +82,7 @@ describe('mergeServerFields', () => { sectionTitle: 'More fields', }); const added = form!.sections![1]; - const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + const fieldNames = added.fields!.map((f) => (typeof f === 'string' ? f : f.field)); expect(fieldNames).not.toContain('objectName'); expect(fieldNames).not.toContain('columns'); // bundled props/sections are untouched (additive only) @@ -125,7 +128,7 @@ describe('mergeServerFields', () => { sectionTitle: 'More fields', }); const added = form!.sections![1]; - const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + const fieldNames = added.fields!.map((f) => (typeof f === 'string' ? f : f.field)); expect(fieldNames).not.toContain('dataset'); expect(fieldNames).toEqual(['rows', 'values', 'runtimeFilter']); expect(schema!.properties.dataset).toEqual(caughtUp.properties.dataset); @@ -157,7 +160,7 @@ describe('mergeServerFields', () => { sectionTitle: 'More fields', }); const added = form!.sections![1]; - const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + const fieldNames = added.fields!.map((f) => (typeof f === 'string' ? f : f.field)); expect(fieldNames).not.toContain('dataset'); }); }); From ff13f7179bd8c2741bec49f339909b3a4a105b21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:44:18 +0000 Subject: [PATCH 2/6] fix(app-shell): type the /meta/types form as FormViewSpec and drop the as-any handoffs (objectui#8725) Arm C, last: RichMetadataTypeEntry.form moves from an untyped record, handed to SchemaForm through `as any` in ResourceEditPage and EmbeddedItemEditor, to the authoring FormViewSpec. It is an honest claim only now that SchemaForm resolves a { group } section and refuses a section with no member source, which is why it lands after them. EmbeddedItemEditor's fallback form slot takes the same type (no fallback entry declares a form today). PIN T2 of the #8725 pin file is rewritten onto the retyped channel in the same commit: the registry form equals FormViewSpec and refuses an undeclared key. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../metadata-admin/EmbeddedItemEditor.tsx | 5 ++-- .../views/metadata-admin/ResourceEditPage.tsx | 6 ++--- ...orm.groupSectionReachability-8725.test.tsx | 25 ++++++++++++++----- .../src/views/metadata-admin/useMetadata.ts | 13 ++++++++-- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx b/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx index 74e6ec68fd..59f34e9bed 100644 --- a/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.tsx @@ -35,6 +35,7 @@ import { } from './SchemaForm.js'; import { getMetadataPreview } from './preview-registry.js'; import { useMetadataClient, useMetadataTypes } from './useMetadata.js'; +import type { FormViewSpec } from './form-spec.js'; import { useMetadataLocale, t, tFormat, translateValidationMessage } from './i18n.js'; import { errorCodeIsAnyOf } from '@object-ui/types'; @@ -73,7 +74,7 @@ export function EmbeddedItemEditor({ // than promised, by `EmbeddedItemEditor.indexFallback.test.tsx`. const fallback = !subEntry?.schema && editAs ? FALLBACK_SCHEMAS[editAs] : undefined; const schema = (subEntry?.schema as Record | undefined) ?? fallback?.schema; - const form = (subEntry?.form as any) ?? fallback?.form; + const form = subEntry?.form ?? fallback?.form; // Opt-in per sub-type, exactly like the Preview tab on the full page: a type // with no registered renderer gets no surface at all (never a "preview not // available" placeholder). @@ -336,7 +337,7 @@ function spliceEmbedded( */ export const FALLBACK_SCHEMAS: Record< string, - { schema: Record; form?: Record } + { schema: Record; form?: FormViewSpec } > = { index: { schema: { diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index d19ffbaef4..0d8b3dbea0 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -1421,7 +1421,7 @@ function MetadataResourceEditPageImpl({ if (!key) return path; // Resolve the human label for the HEAD segment from the form/schema. const headLabel = ((): string => { - const formForLabels = (createMode && config.createSchema ? undefined : (entry?.form as any)); + const formForLabels = (createMode && config.createSchema ? undefined : entry?.form); const sections = Array.isArray(formForLabels?.sections) ? formForLabels.sections : []; for (const section of sections) { const fields = Array.isArray(section?.fields) ? section.fields : []; @@ -2826,7 +2826,7 @@ function MetadataResourceEditPageImpl({ = { + // ── PIN T2 — arm C. The registry entry's `form` is the authoring type, not + // `Record` handed on with `as any`. The type now describes the + // channel that actually carries a form to this renderer. + const registryFormIsTheViewSpec: Assert< + Equal, FormViewSpec> + > = true; + void registryFormIsTheViewSpec; + // The crash document of the original measurement is a legal value of it... + const serverFormWithGroupSection: NonNullable = { type: 'simple', - sections: [{ label: 'Contact', group: 'contact_info' }], + sections: [{ group: 'contact_info' }], }; - void serverRegistryFormAdmitsIt; + void serverFormWithGroupSection; + // ...and a key the form contract does not declare is not. Under the old + // `Record` this directive was unused (TS2578). + const refusesAnUndeclaredKey: NonNullable = { + type: 'simple', + // @ts-expect-error objectui#8725 — not a `FormViewSpec` key. + notAFormViewKey: true, + }; + void refusesAnUndeclaredKey; } const schema = { diff --git a/packages/app-shell/src/views/metadata-admin/useMetadata.ts b/packages/app-shell/src/views/metadata-admin/useMetadata.ts index 4bbebbf784..0544c25885 100644 --- a/packages/app-shell/src/views/metadata-admin/useMetadata.ts +++ b/packages/app-shell/src/views/metadata-admin/useMetadata.ts @@ -22,6 +22,7 @@ import { toast } from 'sonner'; import { usePreviewDrafts } from '../../preview/PreviewModeContext.js'; import { createConsoleMetadataClient } from './metadataClientFactory.js'; import { emitSaveAdvisories, type TranslateFn } from '../../providers/saveAdvisoryToast.js'; +import type { FormViewSpec } from './form-spec.js'; /** * A declarative **type-level** action surfaced on a metadata type by the @@ -89,8 +90,16 @@ export interface RichMetadataTypeEntry { loadOrder?: number; /** JSONSchema for the type's item shape (Phase 3a addition). */ schema?: Record; - /** Canonical FormView layout for the type's editor (Phase 3c+). */ - form?: Record; + /** + * Canonical FormView layout for the type's editor (Phase 3c+). + * + * Typed as the authoring {@link FormViewSpec} rather than an untyped record + * handed on with `as any` (objectui#8725). The document is still deserialised + * without validation, so this type is a claim about the channel, and it is an + * honest one only because `SchemaForm` resolves a `{ group }` section and + * refuses a section with no member source instead of assuming `fields`. + */ + form?: FormViewSpec; /** UI hints (icon, color, etc.) the framework may include. */ ui?: Record; } From b392cbd44f26eca9b0286b72524209ea0426198a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:56:45 +0000 Subject: [PATCH 3/6] test(app-shell): read the #8725 group-diagnostic pin before the test's own resolver call (objectui#8725) An ablation that silenced SchemaForm's report (resolvable false -> true) left the diagnostic pin green: the test's own resolverFieldNames call reported the same dangling group, and the console was read after it. The console is now read immediately after the render. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../SchemaForm.groupSectionReachability-8725.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx index 87dd330273..035735b5af 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx @@ -199,12 +199,16 @@ describe('objectui#8725 — a `{ group }` section in SchemaForm resolves through { group: 'no_such_group_8725' }, ]); const { error, ids } = renderForm(form); + // Read the diagnostics BEFORE this test calls the resolver itself: that call + // reports too, and read afterwards it would satisfy the assertions below on + // its own — measured, by an ablation that silenced SchemaForm's report and + // left this leg green until the read was moved here. + const said = consoleError.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); expect(error).toBeUndefined(); expect(resolverFieldNames(form)[1]).toEqual([]); expect(ids).toEqual(['mdf-a']); - const said = consoleError.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); expect(said).toContain('no_such_group_8725'); expect(said).toContain(SCHEMA_ID); expect(said).toContain('fieldGroups'); From 559ac93e7d0c8058d956892cac160bddc851795e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:00:33 +0000 Subject: [PATCH 4/6] chore(changeset): declare the #8725 type change and date the measurement entry (objectui#8725) One minor changeset for @object-ui/app-shell with the Clause-2 line and a BREAKING (types) banner, and a dated note on the pending measurement entry whose pins this change rewrote. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/8725-formsection-group-resolves.md | 20 +++++++++++++++++++ .changeset/quiet-pans-repeat.md | 2 ++ 2 files changed, 22 insertions(+) create mode 100644 .changeset/8725-formsection-group-resolves.md diff --git a/.changeset/8725-formsection-group-resolves.md b/.changeset/8725-formsection-group-resolves.md new file mode 100644 index 0000000000..2c2521c456 --- /dev/null +++ b/.changeset/8725-formsection-group-resolves.md @@ -0,0 +1,20 @@ +--- +'@object-ui/app-shell': minor +--- + +The metadata-admin `SchemaForm` no longer crashes on a form section that references a field group, and the form-section authoring type accepts that shape (objectui#8725). + +**Clause-②: yes** — two exported types change shape. `FormSectionSpec.fields` becomes optional, which widens what an author may write to match `@objectstack/spec`. `RichMetadataTypeEntry.form` narrows from `Record` to `FormViewSpec`. + +**BREAKING (types).** A consumer that reads `section.fields` unguarded, or indexes `FormSectionSpec['fields'][number]`, no longer compiles (`TS18048` / `TS2537`), because a `{ group }` section carries no `fields`. Read the element type through `NonNullable`. Do not default the list with `?? []`: a `{ group }` section then renders as a silently empty one. A consumer that treated `RichMetadataTypeEntry.form` as an open record now gets the `FormViewSpec` keys only. No published source in this repository had to change. The readers that did change were this package's own tests, one type-level pin and four `mergeServerFields` assertions. + +**What was wrong.** `@objectstack/spec` lets a form section declare its members one of two ways: enumerate `fields`, or point `group` at a declared field group. The `/meta/types` registry hands its form document to `SchemaForm` without validation, and a `{ group }` section reached the renderer's first `fields` read. That read threw `TypeError: s.fields is not iterable` out of the component body and blanked the whole form, well-formed sections included, on both the simple and the tabbed layout. The required `fields` on `FormSectionSpec` only stopped a TypeScript author from writing the shape; the server channel carried it anyway, through an `as any` cast. + +**What changed, in observable terms.** + +- Every read of a section's `fields` in `SchemaForm` now sees a resolved section. A `{ group }` section goes through `@object-ui/plugin-form`'s `resolveSectionGroupReferences`, the resolver `ObjectForm` and the console `FormPage` already use, so `SchemaForm` gives their answer. +- No `SchemaForm` host carries an object definition, and `/meta/types` declares no field groups for a metadata type. So a `{ group }` section in a metadata-type editor names no declared field group. For that case the resolver renders the section empty and reports the dangling key once on `console.error`, naming the group and the form's `schemaId`. The other sections render normally. +- A section that declares neither `fields` nor `group` is still refused out loud. The render throws a `TypeError` that names the section; it does not draw an empty section. +- `RichMetadataTypeEntry.form` is typed `FormViewSpec`, and the `as any` casts where `ResourceEditPage` and `EmbeddedItemEditor` hand it to `SchemaForm` are gone. + +No change for any form whose sections all enumerate `fields`: the resolver returns such a list by identity. diff --git a/.changeset/quiet-pans-repeat.md b/.changeset/quiet-pans-repeat.md index 70624a63cb..79e9af67bc 100644 --- a/.changeset/quiet-pans-repeat.md +++ b/.changeset/quiet-pans-repeat.md @@ -2,3 +2,5 @@ --- Test-only change: pins the objectui#8725 reachability measurement for a `{ group }` form section reaching `SchemaForm`. No published behaviour, type or API changes. + +⚠️ **Dated note, 2026-09-28 — those pins have since been rewritten onto the ruled behaviour — objectui#8725.** The measurement they recorded (a `{ group }` section making `SchemaForm` throw `s.fields is not iterable`) went red when the fix landed, and the same file now pins that `SchemaForm` resolves such a section through `resolveSectionGroupReferences` and still refuses a section with no member source. The rest of this entry is kept as the reading of this change. From 496c63c061ad3f0a44e0727dd48c7c7173deb954 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:09:46 +0000 Subject: [PATCH 5/6] test(console): read the FormPage field-spec pin's element type through NonNullable (objectui#8725) FormSectionSpec.fields is optional now, so the console's type-level PIN A path (section -> fields -> element) indexed a possibly-undefined array (TS2537), and PIN D's excess-key directive went unused behind it. The changeset names this reader among the ones the type change moved. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/8725-formsection-group-resolves.md | 2 +- apps/console/src/components/FormPage.fieldSpec.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/8725-formsection-group-resolves.md b/.changeset/8725-formsection-group-resolves.md index 2c2521c456..8cd4085661 100644 --- a/.changeset/8725-formsection-group-resolves.md +++ b/.changeset/8725-formsection-group-resolves.md @@ -6,7 +6,7 @@ The metadata-admin `SchemaForm` no longer crashes on a form section that referen **Clause-②: yes** — two exported types change shape. `FormSectionSpec.fields` becomes optional, which widens what an author may write to match `@objectstack/spec`. `RichMetadataTypeEntry.form` narrows from `Record` to `FormViewSpec`. -**BREAKING (types).** A consumer that reads `section.fields` unguarded, or indexes `FormSectionSpec['fields'][number]`, no longer compiles (`TS18048` / `TS2537`), because a `{ group }` section carries no `fields`. Read the element type through `NonNullable`. Do not default the list with `?? []`: a `{ group }` section then renders as a silently empty one. A consumer that treated `RichMetadataTypeEntry.form` as an open record now gets the `FormViewSpec` keys only. No published source in this repository had to change. The readers that did change were this package's own tests, one type-level pin and four `mergeServerFields` assertions. +**BREAKING (types).** A consumer that reads `section.fields` unguarded, or indexes `FormSectionSpec['fields'][number]`, no longer compiles (`TS18048` / `TS2537`), because a `{ group }` section carries no `fields`. Read the element type through `NonNullable`. Do not default the list with `?? []`: a `{ group }` section then renders as a silently empty one. A consumer that treated `RichMetadataTypeEntry.form` as an open record now gets the `FormViewSpec` keys only. No published source in this repository had to change. The readers that did change were tests: two type-level pins, one in this package and one in `apps/console` (`FormPage.fieldSpec.test.ts`), and four `mergeServerFields` assertions. **What was wrong.** `@objectstack/spec` lets a form section declare its members one of two ways: enumerate `fields`, or point `group` at a declared field group. The `/meta/types` registry hands its form document to `SchemaForm` without validation, and a `{ group }` section reached the renderer's first `fields` read. That read threw `TypeError: s.fields is not iterable` out of the component body and blanked the whole form, well-formed sections included, on both the simple and the tabbed layout. The required `fields` on `FormSectionSpec` only stopped a TypeScript author from writing the shape; the server channel carried it anyway, through an `as any` cast. diff --git a/apps/console/src/components/FormPage.fieldSpec.test.ts b/apps/console/src/components/FormPage.fieldSpec.test.ts index 04fa347092..c41dc4f103 100644 --- a/apps/console/src/components/FormPage.fieldSpec.test.ts +++ b/apps/console/src/components/FormPage.fieldSpec.test.ts @@ -64,7 +64,9 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? */ type ConsoleFormViewSpec = Parameters[0]; type ConsoleSectionSpec = NonNullable[number]; -type ConsoleFieldSpec = Exclude; +// `fields` is OPTIONAL on the section since objectui#8725 (a `{ group }` section +// carries none), so the element type is read through `NonNullable`. +type ConsoleFieldSpec = Exclude[number], string>; /** * These never run. They are the half of this card a runtime test cannot express From e8422e785ecb285c1097e6736493001dc62473ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:10:13 +0000 Subject: [PATCH 6/6] docs(app-shell): correct two #8725 changeset sentences, the form JSDoc and a tautological pin line (objectui#8725) Contract-review fix round 1, text only: - the changeset's closing line now says a section carrying both `fields` and `group` (refused by @objectstack/spec at parse, re-measured on 17.4.0) takes the resolver's answer for its group instead of rendering its fields; - the BREAKING paragraph no longer says no published source changed: part 1 broke SchemaForm's three reads, which are what the change fixes; - RichMetadataTypeEntry.form's JSDoc calls the retype a type on an unvalidated server document and names the measured shape gaps, without widening it; - the REFUSAL pin drops `expect(html).toBe('')`, which could only pass. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/8725-formsection-group-resolves.md | 4 ++-- ...hemaForm.groupSectionReachability-8725.test.tsx | 9 +++------ .../src/views/metadata-admin/useMetadata.ts | 14 ++++++++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.changeset/8725-formsection-group-resolves.md b/.changeset/8725-formsection-group-resolves.md index 8cd4085661..a13becbd21 100644 --- a/.changeset/8725-formsection-group-resolves.md +++ b/.changeset/8725-formsection-group-resolves.md @@ -6,7 +6,7 @@ The metadata-admin `SchemaForm` no longer crashes on a form section that referen **Clause-②: yes** — two exported types change shape. `FormSectionSpec.fields` becomes optional, which widens what an author may write to match `@objectstack/spec`. `RichMetadataTypeEntry.form` narrows from `Record` to `FormViewSpec`. -**BREAKING (types).** A consumer that reads `section.fields` unguarded, or indexes `FormSectionSpec['fields'][number]`, no longer compiles (`TS18048` / `TS2537`), because a `{ group }` section carries no `fields`. Read the element type through `NonNullable`. Do not default the list with `?? []`: a `{ group }` section then renders as a silently empty one. A consumer that treated `RichMetadataTypeEntry.form` as an open record now gets the `FormViewSpec` keys only. No published source in this repository had to change. The readers that did change were tests: two type-level pins, one in this package and one in `apps/console` (`FormPage.fieldSpec.test.ts`), and four `mergeServerFields` assertions. +**BREAKING (types).** A consumer that reads `section.fields` unguarded, or indexes `FormSectionSpec['fields'][number]`, no longer compiles (`TS18048` / `TS2537`), because a `{ group }` section carries no `fields`. Read the element type through `NonNullable`. Do not default the list with `?? []`: a `{ group }` section then renders as a silently empty one. A consumer that treated `RichMetadataTypeEntry.form` as an open record now gets the `FormViewSpec` keys only. Outside `SchemaForm`, whose three reads are what this change fixes, no published source in this repository had to change. The other readers that changed were tests: two type-level pins, one in this package and one in `apps/console` (`FormPage.fieldSpec.test.ts`), and four `mergeServerFields` assertions. **What was wrong.** `@objectstack/spec` lets a form section declare its members one of two ways: enumerate `fields`, or point `group` at a declared field group. The `/meta/types` registry hands its form document to `SchemaForm` without validation, and a `{ group }` section reached the renderer's first `fields` read. That read threw `TypeError: s.fields is not iterable` out of the component body and blanked the whole form, well-formed sections included, on both the simple and the tabbed layout. The required `fields` on `FormSectionSpec` only stopped a TypeScript author from writing the shape; the server channel carried it anyway, through an `as any` cast. @@ -17,4 +17,4 @@ The metadata-admin `SchemaForm` no longer crashes on a form section that referen - A section that declares neither `fields` nor `group` is still refused out loud. The render throws a `TypeError` that names the section; it does not draw an empty section. - `RichMetadataTypeEntry.form` is typed `FormViewSpec`, and the `as any` casts where `ResourceEditPage` and `EmbeddedItemEditor` hand it to `SchemaForm` are gone. -No change for any form whose sections all enumerate `fields`: the resolver returns such a list by identity. +No change for any form none of whose sections carries a `group` key: the resolver returns such a list by identity. A section carrying both `fields` and `group`, a shape `@objectstack/spec` refuses at parse, now takes the resolver's answer for its `group` (in a metadata-type editor, an empty section and the same `console.error`) instead of rendering its enumerated `fields`. diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx index 035735b5af..cbeb5c985e 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx @@ -118,20 +118,18 @@ function serverForm(sections: unknown[], type = 'simple'): FormViewSpec { return { type, data: { provider: 'schema', schemaId: SCHEMA_ID }, sections } as unknown as FormViewSpec; } -function renderForm(form: FormViewSpec): { error: Error | undefined; html: string; ids: string[] } { +function renderForm(form: FormViewSpec): { error: Error | undefined; ids: string[] } { let error: Error | undefined; - let html = ''; let ids: string[] = []; try { const { container } = render( {}} />, ); - html = container.innerHTML; ids = Array.from(container.querySelectorAll('input[id^="mdf-"]')).map((el) => el.id); } catch (err) { error = err as Error; } - return { error, html, ids }; + return { error, ids }; } /** @@ -242,7 +240,7 @@ describe('objectui#8725 — a section with NO member source is still refused out // reference. It stays a THROW out of the render — not an empty section — and // the throw names the section it refuses. it('neither `fields` nor `group`: the render throws a TypeError naming the section', () => { - const { error, html } = renderForm( + const { error } = renderForm( serverForm([ { label: 'Basics', fields: ['a'] }, { label: 'Contact' }, @@ -253,7 +251,6 @@ describe('objectui#8725 — a section with NO member source is still refused out expect(error?.message).toContain('"Contact"'); expect(error?.message).toContain('`fields`'); expect(error?.message).toContain('`group`'); - expect(html).toBe(''); }); it('the tabbed arm refuses the same section the same way', () => { diff --git a/packages/app-shell/src/views/metadata-admin/useMetadata.ts b/packages/app-shell/src/views/metadata-admin/useMetadata.ts index 0544c25885..004272b407 100644 --- a/packages/app-shell/src/views/metadata-admin/useMetadata.ts +++ b/packages/app-shell/src/views/metadata-admin/useMetadata.ts @@ -94,10 +94,16 @@ export interface RichMetadataTypeEntry { * Canonical FormView layout for the type's editor (Phase 3c+). * * Typed as the authoring {@link FormViewSpec} rather than an untyped record - * handed on with `as any` (objectui#8725). The document is still deserialised - * without validation, so this type is a claim about the channel, and it is an - * honest one only because `SchemaForm` resolves a `{ group }` section and - * refuses a section with no member source instead of assuming `fields`. + * handed on with `as any` (objectui#8725). This is a TYPE on a server + * document that is deserialised without validation, not a check of it. Known + * gaps between the spec's parsed `FormView` and this type (measured on + * objectui#8725's PR, not re-derived here): the spec admits its `I18nLabel` + * locale map on section and field `label` and on `placeholder` / `helpText`, + * writes predicates as `ExpressionInput` rather than `VisibilityPredicate`, + * types `colSpan` and `options` wider, and parses field rows with keys + * `FormFieldSpec` does not declare (`span`, `keyField`). What `SchemaForm` + * itself now guarantees is narrower: it resolves a `{ group }` section and + * refuses one with no member source instead of assuming `fields`. */ form?: FormViewSpec; /** UI hints (icon, color, etc.) the framework may include. */