Skip to content

Commit 67c2e3a

Browse files
fix(plugin-form): a form section naming a field the object lacks draws no control (#29194)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbX2BpbF4EuTV9iEpP8DVMBf)_ Fixes #29021 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:表单视图的分区里如果写了对象上不存在的字段名(拼错或字段已删除),tabbed / wizard / drawer / modal 四种布局不再为它画一个可编辑的输入框;simple 布局本来就不画。 - 为什么改:以前用户能在这个框里输入,保存时值被静默丢弃(保存只写对象声明过的字段),且因为“没改任何真实字段”,保存会把整条记录都发出去。lint 的 `form-field-unknown` 早就说这种字段“不会渲染”,现在表单与 lint 一致。 - 风险与回滚:只影响“对象 schema 已加载、且名字不在对象上、也没有 customFields 成员定义它”的分区条目;没有对象 schema 的表单(无数据源、由宿主 submitHandler 负责写入)保持原样。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-form/src/sectionFields.ts`(`fromObjectSchema` 与 `buildSectionFields`),`ui/packages/plugin-form/src/__tests__/unknownSectionField-29021.test.tsx`,`ui/packages/plugin-form/src/flatFields.ts`。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/plugin-form/src/__tests__/unknownSectionField-29021.test.tsx`,预期 8 个用例全绿(四种布局各两条)。 - 需要你做的:无,席位落地。 ## What changes `fromObjectSchema` (`ui/packages/plugin-form/src/sectionFields.ts`) used to answer a name the object does not declare with a bare `{ name, label: name, type: 'input' }` stub. The four arms that build a section with no field pool (`tabbed`, `wizard`, `drawer`, `modal`) drew it as a live text box labelled with the raw name. It now answers `undefined` when an object schema is resolved and does not declare the name, `normalizeSectionField` passes that through, and `buildSectionFields` drops the entry. This is the answer the lint's `FORM_FIELD_UNKNOWN` already gives ("silently skipped and never renders on the form") and the one the default arm's pool and `buildFlatFields` already gave. What is still drawn, unchanged: - a `customFields` member naming the field (it is a whole definition); - a self-describing inline entry carrying its own `name` (shape 3); - a bare name on a form with NO object schema at all, such as a form mounted without a data source whose host `submitHandler` owns the write (`submitTargetRefusal.test.tsx`). There is no object to call the name unknown, the lint likewise judges only a known object, and the value does reach that host. An authored `type` or `label` on a spec `{ field }` entry does not make the field exist: such an entry is dropped too, as the lint reports it. **Empty sections.** A section whose every member is unknown now builds no field. Each arm keeps whatever it already does with an empty section; nothing here changes that. On the default arm this is the existing behaviour pinned by `objectFormSectionMembers-8071` row 2 (the section is dropped whole, heading included). **Design and preview paths.** No designer relies on the fallback. Studio's object form designer (`StudioDesignSurface`) renders a flat `object-form` with top-level `fields` (the `buildFlatFields` / pool path, which already skipped unknown names). The metadata-admin view preview (`ViewPreview`) renders the draft form view through the same `object-form` arms, so it now shows what the record form shows; the author's signal for the unknown name stays the lint's `form-field-unknown`. `fromObjectSchema` and `normalizeSectionField` now return `FormField | undefined`. Neither is exported from the package entry (only `sectionEntryName` is), so the public surface is unchanged. `flatFields.ts` reads the new answer instead of its own duplicate declaration check. ## Tests - New `ui/packages/plugin-form/src/__tests__/unknownSectionField-29021.test.tsx`: for each of `tabbed`, `wizard`, `drawer`, `modal`, an EDIT form of a real record whose section lists `title` beside `no_such_field` (bare name) and `{ field: 'other_ghost', label: 'Other ghost' }`: 1. only `title` is drawn, no control and no label for either unknown name (the known input with its loaded value is the non-vacuity control); 2. editing `title` and saving calls `dataSource.update('task', 't1', payload)` with `title: 'New title'` and no key for either unknown name. - `sectionFields.test.ts`: `normalizeSectionField` builds nothing for an unknown name in both named shapes (also with an authored `type`); with `objectSchema: null` the bare stub is still built (the boundary); `buildSectionFields` drops unknown entries keeping known ones in order, builds `[]` for an all-unknown section, and still draws a `customFields` member and an inline entry. - Existing rows that used an undeclared `ghost` field as a blank slate now use a declared bare field (`sectionFields.spec-parity.test.ts` declares `plain: { type: 'number' }`); `selectMultipleGroupLabel.test.tsx`'s "keeps its fallback type" row now pins that no field is built. Other call sites take `!` where the field is declared. ## Acceptance notes - **Ablation.** Restoring the fallback (deleting `if (ctx.objectSchema != null) return undefined;` through `scripts/ablation-replace.mjs`, anchor 1 -> 0, blob restored to HEAD after) turned 7 cases red: the four "draws only the known field" rows and three `sectionFields.test.ts` rows. The four save rows stay green under the ablation, as expected: the stub's value was already stripped on save before this change (the card saw no `no_such_field` key either). They pin the save half so it cannot regress once the box is gone. - **The whole-record PATCH (finding, save path unchanged).** Measured in the same harness with the fallback restored, typing `ghost` into the stub and saving sent `{"title":"Old title","notes":"kept"}`, every drawn field, on all four arms. That is `dirtyEditPayload` (`sanitize.ts`) by design: the unknown key is stripped, so the diff against the loaded record is empty, and an empty diff sends the whole payload so the save stays a real write. With this fix the ghost box is gone, so typing into it can no longer produce that save. A save with nothing changed still sends every drawn field (measured: the same payload on all four arms). That is the documented empty-diff rule, left as is per the card's ruling. - **Publish-time validity (finding, out of lane).** `_diagnostics` on the metadata read door is the Zod verdict (`packages/metadata-protocol/src/metadata-diagnostics.ts`); `validateFormLayout`, which owns `form-field-unknown`, is wired into the authoring-rule registry (`os validate` / `os lint`) and not into the door, so a form view naming an unknown field still reads `valid: true` there. That belongs to `domain:server`; nothing under `packages/**` is touched here. - **Dev showcase reproduction: NOT MEASURED.** It needs a full workspace build and a dev server. The record edit route renders the form view through `ObjectForm`'s `tabbed` arm, the same component the render pins drive. ## Verification All readings at head `49b88da9` (main `fea2dbb7` merged in; the 14 later main commits touch nothing under `ui/packages/plugin-form`). <details><summary>plugin-form suite: 218 files, 2419 tests green</summary> `cd ui && pnpm exec vitest run --maxWorkers=2 FILES`, under `os-verify-lock`, in two halves of the package's 218 test files: - first 110 files: Test Files 110 passed (110), Tests 872 passed (872), VERDICT command-exit 0; - last 108 files: Test Files 108 passed (108), Tests 1547 passed, 1 skipped (1548), VERDICT command-exit 0. </details> <details><summary>type-check and lint</summary> - `pnpm exec turbo run type-check --concurrency=2 --filter=@object-ui/plugin-form`: Tasks 48 successful, 48 total (covers `tsconfig.test.json`, so the test files are compiled). - `pnpm exec eslint` on the seven touched files: exit 0, 0 errors (59 pre-existing `no-explicit-any` warnings). </details> <details><summary>ablation</summary> `node scripts/ablation-replace.mjs --file ui/packages/plugin-form/src/sectionFields.ts --anchor 'if (ctx.objectSchema != null) return undefined;' ...` (anchor 1 -> 0, blob d6b6782 -> 50ccedea3703), then the new render pin plus `sectionFields.test.ts`: Test Files 2 failed (2), Tests 7 failed, 37 passed (44). Restored: blob == HEAD (d6b6782), `git diff HEAD` empty. </details> <details><summary>derived gates: 34 of 34 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands from this diff; each ran and exited 0, including `node scripts/check-sdui-manifest.mjs` (manifest intact, 106 components), the changeset gates against this body, `pnpm check:type-check-debt` (0 raw tsc errors above ledger) and `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped). `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTfpYE3Paxw3g66yLu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31670f5 commit 67c2e3a

8 files changed

Lines changed: 284 additions & 44 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
---
4+
5+
fix(plugin-form): a form section naming a field the object does not have draws no control
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A form view section that names a field the object does not declare (a typo, or a field deleted later) used to draw a live text box labelled with the raw name on the `tabbed`, `wizard`, `drawer` and `modal` layouts. Whatever the user typed there was never saved: the record save writes only declared fields, so the value was dropped behind a successful save. Those layouts now draw nothing for such a name, which is what the `form-field-unknown` lint already says ("never renders on the form") and what the default (`simple`) layout already did.
11+
12+
A `customFields` member or a self-describing inline field (one carrying its own `name`) is still drawn, as before. A form with no object schema at all, such as one mounted without a data source whose `submitHandler` owns the write, keeps its bare input for a named field: there is no object to call the name unknown. A section whose every field is unknown is left empty, and each layout treats it as it already treats an empty section.
13+
14+
Fix the field name in the form view, or add the field to the object, to bring the control back.

‎ui/packages/plugin-form/src/__tests__/selectMultipleGroupLabel.test.tsx‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -236,47 +236,45 @@ describe('normalizeSectionField decides the widget from the effective pair (obje
236236
};
237237

238238
it('a string shorthand for a multi select resolves to the multi widget', () => {
239-
expect(normalizeSectionField('regions', ctx).type).toBe('field:multiselect');
239+
expect(normalizeSectionField('regions', ctx)!.type).toBe('field:multiselect');
240240
});
241241

242242
it('a spec field def naming a multi select resolves to the multi widget', () => {
243-
expect(normalizeSectionField({ field: 'regions' }, ctx).type).toBe('field:multiselect');
243+
expect(normalizeSectionField({ field: 'regions' }, ctx)!.type).toBe('field:multiselect');
244244
});
245245

246246
it('a view that restates only `multiple: true` moves the widget too', () => {
247247
// The second door onto the same defect: `multiple` is a spec FormField key,
248248
// so a view can turn a single-value object field into a multi one without
249249
// restating `type`. Resolving the id from `fd.type` before `multiple` had
250250
// been merged would have left this on the single-value widget.
251-
expect(normalizeSectionField({ field: 'stage', multiple: true }, ctx).type).toBe('field:multiselect');
251+
expect(normalizeSectionField({ field: 'stage', multiple: true }, ctx)!.type).toBe('field:multiselect');
252252
});
253253

254254
it('a view that restates only `multiple: false` moves it BACK', () => {
255255
// The inverse, which the same single computation buys: an object-level multi
256256
// field narrowed by the view must land on the single-value widget, whose
257257
// label association is the `for` one.
258-
const f = normalizeSectionField({ field: 'regions', multiple: false }, ctx);
258+
const f = normalizeSectionField({ field: 'regions', multiple: false }, ctx)!;
259259
expect(f.type).toBe('field:select');
260260
expect(f.multiple).toBe(false);
261261
});
262262

263263
it('a view restating both keys agrees with the mapper', () => {
264-
expect(normalizeSectionField({ field: 'stage', type: 'select', multiple: true }, ctx).type).toBe(
264+
expect(normalizeSectionField({ field: 'stage', type: 'select', multiple: true }, ctx)!.type).toBe(
265265
mapFieldTypeToFormType('select', { multiple: true }),
266266
);
267267
});
268268

269269
it('a single-value select is untouched', () => {
270-
expect(normalizeSectionField({ field: 'stage' }, ctx).type).toBe('field:select');
271-
expect(normalizeSectionField('stage', ctx).type).toBe('field:select');
270+
expect(normalizeSectionField({ field: 'stage' }, ctx)!.type).toBe('field:select');
271+
expect(normalizeSectionField('stage', ctx)!.type).toBe('field:select');
272272
});
273273

274-
it('a spec field naming nothing in the object schema keeps its fallback type', () => {
275-
// `fromObjectSchema`'s `input` fallback, unchanged: with no type on either
276-
// side there is nothing to decide, and `multiple` alone must not conjure a
277-
// widget id.
278-
const f = normalizeSectionField({ field: 'ghost', multiple: true }, ctx);
279-
expect(f.type).toBe('input');
280-
expect(f.multiple).toBe(true);
274+
it('a spec field naming nothing in the object schema builds no field at all', () => {
275+
// `fromObjectSchema` no longer falls back to an untyped `input` stub for a
276+
// name the object does not declare (objectstack#29021): `multiple` alone
277+
// must not conjure a widget id, nor a control.
278+
expect(normalizeSectionField({ field: 'ghost', multiple: true }, ctx)).toBeUndefined();
281279
});
282280
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* A form view section naming a field the object does not have draws NOTHING
9+
* for it (objectstack#29021).
10+
*
11+
* ## What went wrong
12+
*
13+
* `fromObjectSchema` (`sectionFields.ts`) answered a name the object does not
14+
* declare with a bare `{ name, label: name, type: 'input' }` stub. Every arm
15+
* that builds a section without a field pool — `tabbed`, `wizard`, `drawer`,
16+
* `modal` — drew it as a live text box labelled with the raw name, so a form
17+
* view naming a mistyped or deleted field showed the user an editable box.
18+
* Whatever was typed there never reached the record: the save writes only
19+
* declared fields, so it was dropped behind a 200 with no error. The lint
20+
* already calls such an entry `FORM_FIELD_UNKNOWN` — "silently skipped and
21+
* never renders on the form" — and the default arm's pool already dropped it;
22+
* these four arms disagreed with both.
23+
*
24+
* ## The rows
25+
*
26+
* Per arm, on an EDIT form of a real record, a section listing a known field
27+
* beside an unknown one in BOTH named shapes (a bare name and the spec
28+
* `{ field }` object):
29+
*
30+
* 1. only the known field is drawn — no control, no label for the unknown
31+
* name (the known field's own input is the non-vacuity control);
32+
* 2. editing the known field and saving sends no key for the unknown name,
33+
* and the known field's edit does arrive.
34+
*
35+
* A section whose EVERY member is unknown builds no field; what each arm does
36+
* with an empty section is its own existing behaviour and not re-pinned here.
37+
*/
38+
39+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
40+
import { render, waitFor, fireEvent, screen, cleanup } from '@testing-library/react';
41+
import React from 'react';
42+
import { registerAllFields } from '@object-ui/fields';
43+
import { ObjectFormContainer } from '../ObjectFormContainer';
44+
import '../index';
45+
46+
vi.mock('@object-ui/components/ui/sonner', async (orig) => {
47+
const actual = await (orig as any)();
48+
return { ...actual, toast: { ...actual.toast, success: vi.fn(), error: vi.fn() } };
49+
});
50+
51+
registerAllFields();
52+
53+
const TASK_OBJECT = {
54+
name: 'task',
55+
fields: {
56+
title: { type: 'text', label: 'Title' },
57+
notes: { type: 'text', label: 'Notes' },
58+
},
59+
};
60+
61+
const RECORD = { id: 't1', title: 'Old title', notes: 'kept' };
62+
63+
/** The four arms that build a section with no field pool. */
64+
const ARMS = ['tabbed', 'wizard', 'drawer', 'modal'] as const;
65+
66+
let dataSource: any;
67+
68+
beforeEach(() => {
69+
dataSource = {
70+
getObjectSchema: vi.fn().mockResolvedValue(TASK_OBJECT),
71+
find: vi.fn().mockResolvedValue({ data: [] }),
72+
findOne: vi.fn().mockResolvedValue({ ...RECORD }),
73+
create: vi.fn().mockResolvedValue({ id: 't1' }),
74+
update: vi.fn().mockResolvedValue({ ...RECORD }),
75+
delete: vi.fn(),
76+
bulk: vi.fn(),
77+
};
78+
});
79+
80+
afterEach(() => {
81+
cleanup();
82+
vi.restoreAllMocks();
83+
});
84+
85+
function renderArm(formType: (typeof ARMS)[number]) {
86+
render(
87+
<ObjectFormContainer
88+
schema={{
89+
type: 'object-form',
90+
objectName: 'task',
91+
mode: 'edit',
92+
recordId: 't1',
93+
formType,
94+
open: true,
95+
submitText: 'Save Now',
96+
sections: [
97+
{
98+
name: 'overview',
99+
label: 'Overview',
100+
fields: ['title', 'no_such_field', { field: 'other_ghost', label: 'Other ghost' }],
101+
},
102+
],
103+
} as any}
104+
dataSource={dataSource}
105+
/>,
106+
);
107+
}
108+
109+
async function knownInput(): Promise<HTMLInputElement> {
110+
return waitFor(() => {
111+
const el = document.querySelector('input[name="title"]') as HTMLInputElement | null;
112+
if (!el) throw new Error('the known field never rendered — the absences below would pass vacuously');
113+
expect(el.value).toBe('Old title');
114+
return el;
115+
});
116+
}
117+
118+
describe.each(ARMS)('formType `%s`: a section member the object does not have', (formType) => {
119+
it('draws only the known field — no control and no label for the unknown names', async () => {
120+
renderArm(formType);
121+
await knownInput();
122+
123+
expect(document.querySelector('[name="no_such_field"]')).toBeNull();
124+
expect(document.querySelector('[name="other_ghost"]')).toBeNull();
125+
expect(screen.queryByText('no_such_field')).toBeNull();
126+
expect(screen.queryByText('Other ghost')).toBeNull();
127+
});
128+
129+
it('editing and saving the record sends no key for the unknown names', async () => {
130+
renderArm(formType);
131+
const title = await knownInput();
132+
133+
fireEvent.change(title, { target: { value: 'New title' } });
134+
fireEvent.click(screen.getByRole('button', { name: /save now/i }));
135+
136+
await waitFor(() => expect(dataSource.update).toHaveBeenCalledTimes(1));
137+
const [objectName, id, payload] = dataSource.update.mock.calls[0];
138+
expect(objectName).toBe('task');
139+
expect(id).toBe('t1');
140+
expect(payload).toMatchObject({ title: 'New title' });
141+
expect(payload).not.toHaveProperty('no_such_field');
142+
expect(payload).not.toHaveProperty('other_ghost');
143+
});
144+
});

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ export interface FlatFieldsContext extends SectionFieldsContext {
5959
* fields flat (no sections).
6060
*
6161
* Fields the object schema does not declare are SKIPPED — the flat containers
62-
* have always dropped them rather than rendering `fromObjectSchema`'s bare
63-
* `input` stub, and a whitelist naming a field the object does not have is an
62+
* have always dropped them (`fromObjectSchema` builds nothing for them, and
63+
* the sectioned arms drop them too), and a whitelist naming a field the object does not have is an
6464
* authoring error the form should not paper over with an untyped control. A
6565
* `customFields` member naming such a field is drawn in its place: the member
6666
* is a whole definition, not an untyped stub.
@@ -81,11 +81,14 @@ export function buildFlatFields(ctx: FlatFieldsContext): FormField[] {
8181
}
8282

8383
return mergeCustomFields(names, ctx.customFields, (name) => {
84-
const field = ctx.objectSchema?.fields?.[name];
85-
if (!field) return undefined;
84+
// `fromObjectSchema` answers `undefined` for a name the object does not
85+
// declare, so the skip below is its own answer, not a second check.
86+
const built = fromObjectSchema(name, ctx);
87+
if (!built) return undefined;
88+
const field = ctx.objectSchema.fields[name];
8689

8790
return {
88-
...fromObjectSchema(name, ctx),
91+
...built,
8992
// Field-group membership (Field.group → object.fieldGroups[].key), read
9093
// by `deriveFieldGroupSections` for the fieldGroups fallback. Carried
9194
// HERE and not in `fromObjectSchema` because that fallback only ever runs

‎ui/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const CHAINS: Array<readonly [string, (pred: unknown, name: string) => ReturnTyp
7676
describe.each(CHAINS)('attachVisibility via %s (objectui#11262, ADR-0137 D4)', (_chain, build) => {
7777
it.each(BLANKS)('%s: no gate on the runtime field, as before — and a [blank] report naming the field', (_label, pred) => {
7878
const name = fresh('blank');
79-
const f = build(pred, name);
79+
const f = build(pred, name)!;
8080
expect(f.name).toBe(name);
8181
expect((f as { visibleOn?: unknown }).visibleOn).toBeUndefined();
8282
const lines = blankLines();
@@ -139,7 +139,7 @@ describe.each([
139139
...BLANKS,
140140
] as const)('%s: no view-level gate on the runtime field, and silent', (_label, pred) => {
141141
const name = fresh('retired');
142-
const f = normalizeSectionField(name, ctxWith(name, { [key]: pred }));
142+
const f = normalizeSectionField(name, ctxWith(name, { [key]: pred }))!;
143143
expect(f.name).toBe(name);
144144
expect((f as { visibleOn?: unknown }).visibleOn).toBeUndefined();
145145
expect(warn).not.toHaveBeenCalled();

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ const objectSchema = {
5151
label: 'Industry',
5252
options: [{ label: 'Tech', value: 'tech' }],
5353
},
54+
// A bare declared field: no label, options, help or rules of its own, so
55+
// every override below lands on it verbatim. A name the object does not
56+
// declare builds no field at all (objectstack#29021), so it cannot be the
57+
// blank slate this table writes on.
58+
plain: { type: 'number' },
5459
},
5560
};
5661

@@ -60,10 +65,10 @@ const ctx = {
6065
fieldLabel: (_obj: string, _name: string, fallback: string) => fallback || _name,
6166
};
6267

63-
/** Normalize a spec field def against a schema that does NOT define `ghost`,
64-
* so every override is observable verbatim (no object-metadata interference). */
68+
/** Normalize a spec field def onto the bare declared `plain` field, so every
69+
* override is observable verbatim (no object-metadata interference). */
6570
const norm = (def: Record<string, unknown>) =>
66-
normalizeSectionField({ field: 'ghost', ...def }, ctx) as any;
71+
normalizeSectionField({ field: 'plain', ...def }, ctx) as any;
6772

6873
/**
6974
* One row per spec FormFieldSchema key: sample authored value → where the
@@ -109,7 +114,7 @@ const TABLE: Record<string, (f: (def: Record<string, unknown>) => any) => void>
109114
immutable: (f) => {
110115
// Locked once the record exists: an edit form draws it disabled, a create
111116
// 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;
117+
const edit = normalizeSectionField({ field: 'plain', immutable: true }, { ...ctx, mode: 'edit', recordId: 'r1' }) as any;
113118
expect(edit.disabled).toBe(true);
114119
expect(edit.immutable).toBeUndefined();
115120
expect(f({ immutable: true }).disabled).toBeFalsy();

0 commit comments

Comments
 (0)