From 8353964019b3182770e9d6948a0836c691bf3dd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 08:30:50 +0000 Subject: [PATCH] fix(plugin-designer): build the Field Designer's `fields` map as own properties and refuse what it cannot carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MetadataFieldsPage.handleFieldsChange` keyed its `fields` map by blind assignment inside a bare `for` loop, which failed silently three ways — all measured on the installed `@objectstack/spec` 17.2.0: - a field named `__proto__` invoked the prototype setter instead of creating a key, so it vanished from the serialised PUT body while the spec (key rule `/^[a-z_][a-z0-9_]*$/`) stood ready to accept it; - a nameless field keyed as the literal string `"undefined"`, which `ObjectSchema.safeParse` accepts — parsed, stored, and read by nothing; - two fields sharing a name collapsed into one entry, the later silently replacing the earlier. The map is now built through `Object.fromEntries`, and the nameless and duplicate lists are refused before `client.save` runs, so a refused list issues no request. The refusal lands in the page's existing error surface rather than being thrown past a fire-and-forget caller. Ported from the sibling object writer app-shell `MetadataService.toFieldsMap` (objectui#6240), down to the refusal wording, so the two writers of the objectui#5761 parity family cannot drift. `fromDesignerField`'s carry-over semantics are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6489-designer-fields-map-keying.md | 32 ++ ...etadataFieldsPage.fieldsMapKeying.test.tsx | 395 ++++++++++++++++++ .../src/MetadataFieldsPage.tsx | 109 ++++- 3 files changed, 528 insertions(+), 8 deletions(-) create mode 100644 .changeset/6489-designer-fields-map-keying.md create mode 100644 packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx diff --git a/.changeset/6489-designer-fields-map-keying.md b/.changeset/6489-designer-fields-map-keying.md new file mode 100644 index 000000000..2af37a0ed --- /dev/null +++ b/.changeset/6489-designer-fields-map-keying.md @@ -0,0 +1,32 @@ +--- +'@object-ui/plugin-designer': patch +--- + +The Field Designer builds an object's `fields` map by defining own properties and refuses +the three field lists a name-keyed map cannot carry (objectui#6489). `MetadataFieldsPage` +keyed the map by blind assignment — `nextFields[f.name] = fromDesignerField(…)` inside a +bare `for` loop — which failed silently in three directions, all measured on the installed +`@objectstack/spec` 17.2.0: + +- **A field named `__proto__` never reached the wire.** `map['__proto__'] = def` invokes the + prototype setter instead of creating a key, so the field vanished from the serialised PUT + body. `__proto__` matches `ObjectSchema.fields`' key rule `/^[a-z_][a-z0-9_]*$/`, so the + spec stood ready to accept the field the client had thrown away. The map is now built + through `Object.fromEntries`, which defines an own property. +- **A nameless field was stored under the literal key `"undefined"`.** Measured: + `ObjectSchema.safeParse` with `fields: { undefined: … }` returns `success = true`, so the + document parsed, persisted, and had no reader anywhere. It is now refused before the + request. +- **Two fields sharing a name collapsed into one entry.** A designer list carrying two + `amount` fields PUT a single entry, the later silently replacing the earlier. Also refused + before the request. + +Both refusals raise before `client.save`, so a refused list issues no PUT at all, and the +message lands in the page's existing error surface naming the offending index — the caller +is fire-and-forget (`void handleFieldsChange(next)`), so throwing past it would show the +author nothing. + +This is the plugin-designer port of the refusals objectui#6240 landed in the sibling object +writer (app-shell's `MetadataService.toFieldsMap`), down to the wording, so the two writers +of the objectui#5761 parity family cannot drift. `fromDesignerField`'s carry-over semantics +are untouched. diff --git a/packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx new file mode 100644 index 000000000..e48bc966f --- /dev/null +++ b/packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx @@ -0,0 +1,395 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6489 — `MetadataFieldsPage` builds its `fields` MAP by defining own + * properties, and refuses the three inputs a name-keyed map cannot carry. + * + * This is the plugin-designer half of the objectui#5761 parity family, ported + * from the app-shell writer objectui#6240 already closed + * (`MetadataService.toFieldsMap`, pinned in + * `MetadataService.objectPayloadFieldsMap.test.ts`). Same three refusals, same + * construction, so the two object writers cannot drift. + * + * The page used to build the map by blind assignment: + * + * const nextFields: Record = {}; + * for (const f of next) nextFields[f.name] = fromDesignerField(f, prevFields[f.name]); + * + * ## Why the spec cannot be the one to catch this + * + * Measured against the installed `@objectstack/spec` 17.2.0 and asserted in + * `the instrument` below — both hazards parse GREEN once they have been keyed: + * + * - `fields: { undefined: … }` => success = true. A nameless field keys as + * the literal string `"undefined"`, so it is STORED, with no reader + * anywhere. A silently corrupt document instead of a loud refusal. + * - `fields: { __proto__: … }` => success = true. `__proto__` matches the + * record's key rule `/^[a-z_][a-z0-9_]*$/`, so it is an authorable field + * name — and `map['__proto__'] = def` does not create a key at all, it + * invokes the prototype setter. The field vanished from the serialised + * body while the spec stood ready to accept it. That is what makes + * `Object.fromEntries` load-bearing here rather than stylistic. + * + * The third refusal, duplicates, is the conversion's OWN hazard: a designer + * list can carry two fields called `amount` and a map cannot, so the later one + * silently swallowed the earlier. + * + * ## Two mechanics this file depends on, both easy to get backwards + * + * - **`{ __proto__: v }` in an object literal SETS THE PROTOTYPE** (Annex + * B.3.1) — it does not create a key. Every fixture below therefore spells + * the key `['__proto__']`, a computed key, which defines an own property. + * A fixture written the plain way tests `{}` with an odd prototype and + * passes for the wrong reason. + * - **`JSON.parse` DOES create an own `__proto__` property**, so a captured + * request body reads the key back honestly. Every assertion here is on the + * PUT bytes, parsed back — the same discipline as the file's siblings + * `MetadataFieldsPage.saveEnvelope` and `.specKeyReference`, and the only + * level at which "the field vanished" is visible at all. + * + * ## Where the refusal surfaces + * + * In the sibling writer the refusal throws to its caller — `MetadataService` is + * a service. Here the caller is `onFieldsChange={(next) => { void + * handleFieldsChange(next); }}`, so a throw would become an unhandled rejection + * and the author would see NOTHING: the same silent failure wearing a different + * spelling. The port therefore raises inside the page's existing error path, so + * the refusal lands in the `metadata-fields-page-error` surface the save path + * already renders. What both writers share is the invariant that matters: the + * refusal happens BEFORE the request, and no PUT is issued. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { ObjectSchema } from '@objectstack/spec/data'; +import { MetadataClient } from '@object-ui/data-objectstack'; +import type { DesignerFieldDefinition } from '@object-ui/types'; + +// --------------------------------------------------------------------------- +// Fixtures — the wire, both directions +// --------------------------------------------------------------------------- + +/** The plain object document most cases start from. */ +const BASE_BODY = { + name: 'probe_widget', + label: 'Widget', + fields: { + name: { type: 'text', label: 'Name', required: true }, + amount: { type: 'number', label: 'Amount', precision: 2 }, + }, +}; + +/** + * A document that ALREADY stores a field named `__proto__`, written with a + * computed key so it is an own property (see the header note). `JSON.stringify` + * in the fetch double emits it as `{"__proto__": …}`, which is what a + * spec-parsed server would send for this spec-legal field name. + */ +const PROTO_BODY = { + name: 'probe_widget', + label: 'Widget', + fields: { + name: { type: 'text', label: 'Name' }, + ['__proto__']: { type: 'text', label: 'Proto', inlineHelpText: 'Stored under a legal name.' }, + }, +}; + +let served: Record = BASE_BODY; + +const envelope = () => ({ + type: 'object', + name: 'probe_widget', + item: served, + lock: 'none', + provenance: 'org', + editable: true, +}); + +interface RecordedDesignerProps { + objectName: string; + fields: DesignerFieldDefinition[]; + onFieldsChange?: (fields: DesignerFieldDefinition[]) => void; + readOnly?: boolean; +} + +let designerProps: RecordedDesignerProps | null = null; + +vi.mock('./FieldDesigner', () => ({ + FieldDesigner: (props: RecordedDesignerProps) => { + designerProps = props; + return null; + }, +})); + +import { MetadataFieldsPage } from './MetadataFieldsPage'; + +/** Every PUT body, parsed back from the bytes that went over the wire. */ +let puts: Array> = []; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +/** Nothing here mocks the client — this is the transport underneath a real one. */ +function realClient(): MetadataClient { + return new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + if (method === 'PUT') { + puts.push(JSON.parse(String(init?.body ?? '{}')) as Record); + return json({ success: true, name: 'probe_widget' }); + } + if (/\/meta\/object\/probe_widget(\?|$)/.test(url)) return json(envelope()); + return json({ items: [] }); + }) as unknown as typeof fetch, + }); +} + +async function renderPage() { + render(); + await waitFor(() => expect(designerProps).not.toBeNull()); +} + +/** The fields map exactly as it went over the wire on the last PUT. */ +function savedFields(): Record> { + return puts[puts.length - 1].fields as Record>; +} + +/** + * Read one entry as an OWN property. Written this way because the whole subject + * of this file is the difference between an own key and the prototype chain: a + * bare `map[name]` would answer for `Object.prototype` and report a field that + * is not there. + */ +function own(map: Record, key: string): unknown { + return Object.prototype.hasOwnProperty.call(map, key) + ? Object.getOwnPropertyDescriptor(map, key)!.value + : undefined; +} + +const field = (name: string, over: Partial = {}): DesignerFieldDefinition => ({ + id: name, + name, + label: name, + type: 'text', + ...over, +}); + +/** Hand the page a field list and let its save path run to completion. */ +async function emit(next: DesignerFieldDefinition[]) { + await act(async () => { + designerProps!.onFieldsChange!(next); + }); +} + +/** The refusal text the page renders, or `null` while there is none. */ +function shownError(): string | null { + return screen.queryByTestId('metadata-fields-page-error')?.textContent ?? null; +} + +const issuesOf = (result: ReturnType): string[] => + result.success ? [] : result.error.issues.map((i) => `${i.code} @ ${i.path.join('.')}`); + +const parseWithFields = (fields: Record) => + ObjectSchema.safeParse({ name: 'account', label: 'Account', fields }); + +beforeEach(() => { + served = BASE_BODY; + puts = []; + designerProps = null; +}); + +afterEach(() => { + designerProps = null; +}); + +// --------------------------------------------------------------------------- + +describe('the instrument', () => { + it('does NOT catch a nameless field once keyed — `{ undefined: … }` parses GREEN', () => { + // The measured reason this page has to be the one that refuses. If this + // ever goes false the guard is still right, but its justification changed. + expect(parseWithFields({ undefined: { type: 'text', label: 'N' } }).success).toBe(true); + }); + + it('treats `__proto__` as a LEGAL field name — the spec would have accepted what assignment threw away', () => { + expect(parseWithFields({ ['__proto__']: { type: 'text', label: 'P' } }).success).toBe(true); + // Control, so the line above is a verdict about this key rather than a + // schema that accepts anything: a camelCase name is refused AT THE KEY. + expect(issuesOf(parseWithFields({ firstName: { type: 'text', label: 'F' } }))).toEqual([ + 'invalid_key @ fields.firstName', + ]); + }); + + it('is JavaScript itself: assignment drops `__proto__`, `Object.fromEntries` keeps it', () => { + // The defect and the fix, stated on plain objects before any claim about + // the page. `JSON.stringify` is what the request body is made of, so the + // second half of each pair is the byte-level consequence. + const assigned: Record = {}; + assigned['__proto__'] = { type: 'text', label: 'P' }; + assigned['amount'] = { type: 'number', label: 'A' }; + expect(Object.keys(assigned)).toEqual(['amount']); + expect(JSON.stringify(assigned)).toBe('{"amount":{"type":"number","label":"A"}}'); + + const built = Object.fromEntries([ + ['__proto__', { type: 'text', label: 'P' }], + ['amount', { type: 'number', label: 'A' }], + ]); + expect(Object.keys(built)).toEqual(['__proto__', 'amount']); + + // …and the nameless half of the same mechanic. + const nameless: Record = {}; + nameless[undefined as unknown as string] = { type: 'text' }; + expect(Object.keys(nameless)).toEqual(['undefined']); + }); + + it('is a fixture rule too: `{ __proto__: v }` in a LITERAL sets the prototype, it does not add a key', () => { + // Why every fixture in this file spells the key `['__proto__']`. A fixture + // written the plain way carries zero own keys and passes for the wrong + // reason — the assertion would be about `{}`. + const plain = { __proto__: { type: 'text', label: 'P' } } as Record; + expect(Object.keys(plain)).toEqual([]); + const computed = { ['__proto__']: { type: 'text', label: 'P' } } as Record; + expect(Object.keys(computed)).toEqual(['__proto__']); + // And the reason assertions on captured bytes are honest: JSON.parse + // defines an own property rather than invoking the setter. + expect(Object.keys(JSON.parse('{"__proto__":{"type":"text"}}'))).toEqual(['__proto__']); + }); +}); + +describe('objectui#6489 · the map the page PUTs is built as own properties', () => { + it('C0 (control): an ordinary edit still PUTs one name-keyed map, in declaration order', async () => { + // Green before and after this card. Its job is to prove the cases below + // are about the map construction and not about a save that stopped firing + // — and to hold `fromDesignerField`'s carry-over, which this card must not + // touch (objectui#6488 copied that exact form into the app-shell writer). + await renderPage(); + await emit([ + field('name', { label: 'Full name', required: true }), + field('amount', { type: 'number', label: 'Amount' }), + ]); + await waitFor(() => expect(puts).toHaveLength(1)); + + expect(Object.keys(savedFields())).toEqual(['name', 'amount']); + expect(savedFields().name).toMatchObject({ type: 'text', label: 'Full name', required: true }); + // The per-field key the designer renders no control for: it survives only + // through `carryOver(prev)`, which the new construction still feeds. + expect(savedFields().amount.precision).toBe(2); + expect(shownError()).toBeNull(); + }); + + it('H2: a field named `__proto__` reaches the wire instead of vanishing', async () => { + // THE case this card exists for. Built by assignment the entry invoked the + // prototype setter and never appeared in the serialised body at all, while + // the spec (see `the instrument`) stood ready to accept it. + await renderPage(); + await emit([ + field('name', { label: 'Name' }), + field('__proto__', { label: 'Proto' }), + field('amount', { type: 'number', label: 'Amount' }), + ]); + await waitFor(() => expect(puts).toHaveLength(1)); + + expect(Object.keys(savedFields())).toEqual(['name', '__proto__', 'amount']); + expect(own(savedFields(), '__proto__')).toMatchObject({ type: 'text', label: 'Proto' }); + // The whole body, not just the map: the key really is in the bytes. + expect(JSON.stringify(puts[0])).toContain('"__proto__"'); + expect(shownError()).toBeNull(); + }); + + it('H2 (round trip): a stored `__proto__` field loads, and its unknown server key carries back out', async () => { + // The read half. `toDesignerField` walks `Object.entries`, which sees the + // own property `JSON.parse` created — so the field is offered for editing, + // and re-saving it must not drop what the server sent inside it. + served = PROTO_BODY; + await renderPage(); + expect(designerProps!.fields.map((f) => f.name)).toEqual(['name', '__proto__']); + + await emit(designerProps!.fields.map((f) => (f.name === '__proto__' ? { ...f, label: 'Renamed' } : f))); + await waitFor(() => expect(puts).toHaveLength(1)); + + expect(Object.keys(savedFields())).toEqual(['name', '__proto__']); + expect(own(savedFields(), '__proto__')).toMatchObject({ + type: 'text', + label: 'Renamed', + inlineHelpText: 'Stored under a legal name.', + }); + }); +}); + +describe('objectui#6489 · the three inputs a name-keyed map cannot carry are REFUSED before the request', () => { + it('H1: a nameless field is refused — nothing is sent, and the author is told', async () => { + // Not a throw into the void: `onFieldsChange` is fire-and-forget, so the + // refusal has to land where the page already shows save failures. + await renderPage(); + await emit([ + field('name'), + { id: 'x', label: 'Nameless', type: 'text' } as unknown as DesignerFieldDefinition, + ]); + await waitFor(() => expect(shownError()).not.toBeNull()); + + expect(shownError()).toMatch(/has no `name`/); + // The half a "it failed" assertion cannot see: no `{ undefined: … }` entry + // was written, because no request was issued at all. + expect(puts).toHaveLength(0); + }); + + it('H1: the message names the offending position, so it is actionable', async () => { + await renderPage(); + await emit([ + field('name'), + { id: 'x', label: 'Nameless', type: 'text' } as unknown as DesignerFieldDefinition, + ]); + await waitFor(() => expect(shownError()).not.toBeNull()); + expect(shownError()).toMatch(/index 1/); + }); + + it('H1: a blank name counts as no name — `" "` would key as whitespace', async () => { + await renderPage(); + await emit([field(' ', { label: 'Blank' })]); + await waitFor(() => expect(shownError()).not.toBeNull()); + + expect(shownError()).toMatch(/has no `name`/); + expect(puts).toHaveLength(0); + }); + + it('H3: duplicate names are refused rather than collapsed into one entry', async () => { + // An array carries two entries called `amount`; a map cannot, so the second + // silently swallowed the first. The loss is introduced BY the conversion, + // so the conversion is what has to refuse it. + await renderPage(); + await emit([ + field('amount', { type: 'number', label: 'Amount' }), + field('amount', { label: 'Amount again' }), + ]); + await waitFor(() => expect(shownError()).not.toBeNull()); + + expect(shownError()).toMatch(/duplicate field name `amount`/); + expect(shownError()).toMatch(/index 1/); + expect(puts).toHaveLength(0); + }); + + it('a refusal is not a dead end — the next valid save goes out', async () => { + // The refusal leaves no half-written state behind: it happens before the + // request, so correcting the list and saving again just works. + await renderPage(); + await emit([field('amount'), field('amount', { label: 'Dup' })]); + await waitFor(() => expect(shownError()).not.toBeNull()); + expect(puts).toHaveLength(0); + + await emit([field('amount', { type: 'number', label: 'Amount' })]); + await waitFor(() => expect(puts).toHaveLength(1)); + expect(Object.keys(savedFields())).toEqual(['amount']); + }); +}); diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index 7150ee2e4..37b909af1 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -212,6 +212,99 @@ function fromDesignerField( }; } +/** + * Key the designer's field list by field NAME — the shape `ObjectSchema.fields` + * requires — and refuse the three lists that shape cannot carry + * (objectui#6489). + * + * Ported from the sibling object writer, app-shell's + * `MetadataService.toFieldsMap` (objectui#6240), deliberately down to the + * refusal wording: the two writers are the objectui#5761 parity family, and a + * difference between them is a defect waiting to be found twice. + * + * ## Why `Object.fromEntries` and not assignment into a literal + * + * `map['__proto__'] = def` does not create a key — it invokes the prototype + * setter — and `__proto__` is a SPEC-LEGAL field name (`ObjectSchema.fields`' + * key schema is `/^[a-z_][a-z0-9_]*$/`, which it matches). Built by assignment, + * such a field disappeared from the serialised PUT body while the spec stood + * ready to accept it. Measured on `@objectstack/spec` 17.2.0: + * + * ObjectSchema.safeParse({ …, fields: { ['__proto__']: { type: 'text', label: 'P' } } }) + * => success = true + * + * `Object.fromEntries` defines an own property instead. This is what makes the + * construction load-bearing rather than stylistic. + * + * ## Why a missing name THROWS instead of writing `{ undefined: … }` + * + * `DesignerFieldDefinition.name` is declared required, but this page is handed + * whatever the in-memory designer model holds. A nameless field keys as the + * literal string `"undefined"` — and the spec does NOT catch that either: + * + * ObjectSchema.safeParse({ …, fields: { undefined: { type: 'text', label: 'N' } } }) + * => success = true + * + * So it parses, it is STORED, and no reader anywhere looks for it: a silently + * corrupt document in place of a loud refusal. + * + * ## Why a duplicate name throws too + * + * That one is the conversion's OWN hazard rather than an inherited one: the + * designer's list can carry two fields called `amount` and a map cannot, so the + * later entry silently swallowed the earlier. Refusing is the only reading that + * does not lose a field the author declared. + * + * The caller runs this inside its save `try`, so a refusal lands in the page's + * existing error surface. That is the one deliberate difference from the + * sibling writer, and it is forced by the caller's shape: `onFieldsChange` is + * fire-and-forget (`void handleFieldsChange(next)`), so throwing to it would + * produce an unhandled rejection and show the author nothing — the same silent + * failure this function exists to end. The property both writers do share is + * the one that matters: it raises BEFORE the request, so a refused list issues + * no PUT at all. + */ +function toFieldsMap( + next: DesignerFieldDefinition[], + prevFields: Record, +): Record { + const entries: Array<[string, ServerFieldSchema]> = []; + const seen = new Set(); + + next.forEach((designed, index) => { + const name = designed?.name; + if (typeof name !== 'string' || name.trim() === '') { + throw new Error( + `[MetadataFieldsPage] cannot build the object's \`fields\` map: the field at index ${index} has no ` + + '`name`. `ObjectSchema.fields` is keyed by field name, so a nameless field would be written under ' + + 'the literal key "undefined" — which the spec ACCEPTS, leaving a corrupt document stored with ' + + 'nothing to report it. Give the field a name.', + ); + } + if (seen.has(name)) { + throw new Error( + `[MetadataFieldsPage] cannot build the object's \`fields\` map: duplicate field name \`${name}\` at ` + + `index ${index}. A name-keyed map cannot carry two fields under one name, so the later one would ` + + 'silently replace the earlier. Rename or remove one of them.', + ); + } + seen.add(name); + // The carried-over previous definition is read as an OWN property for the + // same reason the map is BUILT as own properties: `prevFields[name]` answers + // out of `Object.prototype` for the two spec-legal names that live there + // (`__proto__`, `constructor`). Measured, that read is harmless today — + // `carryOver` spreads whatever it gets, and both prototype values spread to + // `{}`, so the emitted field is identical either way — but the harmlessness + // is `carryOver`'s to lose, and this function should not depend on it. + const prev = Object.prototype.hasOwnProperty.call(prevFields, name) + ? prevFields[name] + : undefined; + entries.push([name, fromDesignerField(designed, prev)]); + }); + + return Object.fromEntries(entries); +} + export interface MetadataFieldsPageProps { /** Object name to edit fields for (e.g. `account`, `sys_permission_set`). */ objectName: string; @@ -288,15 +381,15 @@ export function MetadataFieldsPage({ // Rebuild the fields map preserving prior unknown keys per field, and // dropping anything the designer removed. const prevFields = state.raw.fields ?? {}; - const nextFields: Record = {}; - for (const f of next) { - nextFields[f.name] = fromDesignerField(f, prevFields[f.name]); - } - const mergedObject: ServerObjectSchema = { - ...state.raw, - fields: nextFields, - }; try { + // Inside the `try` on purpose: `toFieldsMap` REFUSES a field list a + // name-keyed map cannot carry (objectui#6489), and this is the page's one + // error surface. It raises before `client.save`, so a refused list issues + // no request — see the note on `toFieldsMap`. + const mergedObject: ServerObjectSchema = { + ...state.raw, + fields: toFieldsMap(next, prevFields), + }; await client.save('object', objectName, mergedObject); await reload(); } catch (err) {