Skip to content

Commit 1f8dfd1

Browse files
os-zhuangclaude
andcommitted
fix(form): spec-vocabulary fields stop crashing the standalone form; every surface names the boundary (#3090)
Writing the regression test against the unfixed renderer proved the failure was worse than the assumed silent drop: a { field: 'x' } entry (spec form-VIEW vocabulary) slipped past the f?.name guards into a react-hook-form Controller with name === undefined and crashed the WHOLE standalone form on name.split('.'), with nothing naming the culprit. The renderer now partitions such entries out — the rest of the form renders — and surfaces them: an inline alert naming the fields, plus a console.error whose text is the fix instruction an iterating agent will actually follow. objectui validate grows the same boundary awareness: on failure a directed "likely cause" hint replaces the bare invalid_union for spec-shaped entries (the old message read as "bolt a name on", which converts spec metadata wrongly); on success, mixed-vocabulary entries get a warning that the spec key is dead weight. normalizeSectionField warns once per site on mixed identity keys, where the authored name was silently overwritten. Detector precision is pinned by tests: section fields (object-form) legitimately accept the spec shape, and a runtime field's `field` key legitimately holds the resolved metadata OBJECT — only a STRING field without a name marks the boundary violation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7639a61 commit 1f8dfd1

8 files changed

Lines changed: 419 additions & 4 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@object-ui/components": patch
3+
"@object-ui/plugin-form": patch
4+
"@object-ui/cli": patch
5+
---
6+
7+
fix(form): a spec-vocabulary field no longer crashes the standalone form, and every surface now says which vocabulary you meant — #3090
8+
9+
Writing the regression test against the unfixed renderer proved the failure
10+
was worse than the assumed silent drop: a `{ field: 'x' }` entry (spec
11+
form-VIEW vocabulary) slipped past the `f?.name` guards into a
12+
react-hook-form Controller with `name === undefined` and crashed the whole
13+
standalone form on `name.split('.')`, with nothing naming the culprit entry.
14+
The renderer now partitions such entries out — the rest of the form renders —
15+
and surfaces them with an inline alert plus a console.error whose text is the
16+
fix instruction (rename to `name`, or use an object-bound form whose sections
17+
accept the spec shape).
18+
19+
`objectui validate` grows the same boundary awareness: on failure, a
20+
`{ field: … }` entry in a standalone form gets a "likely cause" hint naming
21+
the real fix instead of the bare `invalid_union` — the previous message read
22+
as "bolt a `name` on", which converts spec metadata wrongly. On success,
23+
mixed-vocabulary entries (`name` + string `field`) get a warning: they
24+
validate, but the spec key is dead weight the renderer ignores.
25+
26+
`normalizeSectionField` warns (once per site) when an authored section field
27+
mixes both identity keys — the spec branch derives the runtime name from
28+
`field`, so an authored `name` was silently overwritten.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
9+
/**
10+
* `findSpecVocabularyFormFields` (#3090) — the detector behind `objectui
11+
* validate`'s directed hint. Its precision is the point: the same `field` key
12+
* means three different things across the platform (spec string reference /
13+
* runtime metadata object / absent), and flagging the wrong one would make
14+
* the CLI teach agents the wrong lesson.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { findSpecVocabularyFormFields } from '../utils/spec-vocabulary-hint.js';
19+
20+
describe('findSpecVocabularyFormFields', () => {
21+
it('finds a spec-shaped entry inside a nested standalone form, with its path', () => {
22+
const schema = {
23+
type: 'page',
24+
children: [
25+
{ type: 'text', content: 'hi' },
26+
{
27+
type: 'form',
28+
fields: [
29+
{ name: 'subject', type: 'input' },
30+
{ field: 'owner', required: true },
31+
],
32+
},
33+
],
34+
};
35+
expect(findSpecVocabularyFormFields(schema)).toEqual([
36+
{ path: 'children[1].fields[1]', field: 'owner' },
37+
]);
38+
});
39+
40+
it('reports a mixed-vocabulary entry with its runtime name', () => {
41+
const schema = {
42+
type: 'form',
43+
fields: [{ name: 'subject', field: 'subject_line', type: 'input' }],
44+
};
45+
expect(findSpecVocabularyFormFields(schema)).toEqual([
46+
{ path: 'fields[0]', field: 'subject_line', mixedName: 'subject' },
47+
]);
48+
});
49+
50+
it('does NOT flag section fields — the object-form path accepts the spec shape', () => {
51+
const schema = {
52+
type: 'object-form',
53+
objectName: 'case',
54+
sections: [{ name: 's1', fields: [{ field: 'owner', required: true }] }],
55+
};
56+
expect(findSpecVocabularyFormFields(schema)).toEqual([]);
57+
});
58+
59+
it('does NOT flag the runtime metadata-object `field` slot (the pun)', () => {
60+
const schema = {
61+
type: 'form',
62+
fields: [{ name: 'amount', type: 'input', field: { type: 'currency', scale: 2 } }],
63+
};
64+
expect(findSpecVocabularyFormFields(schema)).toEqual([]);
65+
});
66+
67+
it('handles primitives, nulls and arrays without faulting', () => {
68+
expect(findSpecVocabularyFormFields(null)).toEqual([]);
69+
expect(findSpecVocabularyFormFields('form')).toEqual([]);
70+
expect(findSpecVocabularyFormFields([{ type: 'form', fields: [{ field: 'x' }] }])).toEqual([
71+
{ path: '[0].fields[0]', field: 'x' },
72+
]);
73+
});
74+
});

packages/cli/src/commands/validate.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { readFileSync, existsSync } from 'fs';
1111
import { resolve } from 'path';
1212
import { load as loadYaml } from 'js-yaml';
1313
import { safeValidateSchema } from '@object-ui/types/zod';
14+
import { findSpecVocabularyFormFields } from '../utils/spec-vocabulary-hint.js';
1415

1516
/**
1617
* Validate a schema file
@@ -76,7 +77,21 @@ export async function validate(schemaPath: string) {
7677
if (data.children && Array.isArray(data.children)) {
7778
console.log(chalk.gray(' Children:'), data.children.length);
7879
}
79-
80+
81+
// Mixed-vocabulary entries validate (the runtime `name` satisfies the
82+
// schema) but the spec `field` key is dead weight: the renderer ignores
83+
// it and strip-mode validation drops it. Valid ≠ clean — say so (#3090).
84+
const mixed = findSpecVocabularyFormFields(schema).filter((f) => f.mixedName);
85+
if (mixed.length > 0) {
86+
console.log(chalk.yellow('\nWarnings:'));
87+
for (const m of mixed) {
88+
console.log(chalk.yellow(
89+
` ${m.path}: '${m.mixedName}' also carries { field: '${m.field}' } — mixed form-field ` +
90+
`vocabularies. The renderer ignores \`field\` here; drop one of the two.`,
91+
));
92+
}
93+
}
94+
8095
console.log('');
8196
process.exit(0);
8297
} else {
@@ -97,7 +112,28 @@ export async function validate(schemaPath: string) {
97112
console.error(chalk.gray(` Code: ${issue.code}`));
98113
}
99114
});
100-
115+
116+
// "name: expected string, received undefined" on a `{ field: … }` entry
117+
// reads as an instruction to bolt a `name` on — which converts the
118+
// metadata WRONGLY (the spec shape stands for an object-schema merge
119+
// that a bare rename throws away). When the input matches that
120+
// signature, name the actual boundary and the real fixes (#3090).
121+
const specShaped = findSpecVocabularyFormFields(schema).filter((f) => !f.mixedName);
122+
if (specShaped.length > 0) {
123+
console.error(chalk.bold('\nLikely cause — spec form-view vocabulary in a standalone form:'));
124+
for (const s of specShaped) {
125+
console.error(chalk.yellow(
126+
` ${s.path}: { field: '${s.field}' } is the spec form-VIEW shape (an object-field reference).`,
127+
));
128+
}
129+
console.error(chalk.yellow(
130+
` A standalone \`type: 'form'\` component uses { name: … } (the form data path).\n` +
131+
` Fix: author the field with \`name\` and its own type/label — or, to reference object fields\n` +
132+
` with the spec shape, use an object-bound form (\`type: 'object-form'\` with objectName +\n` +
133+
` sections), whose section fields are translated by the renderer.`,
134+
));
135+
}
136+
101137
console.error('');
102138
process.exit(1);
103139
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
9+
/**
10+
* Spec-vocabulary detector for `objectui validate` (#3090).
11+
*
12+
* `{ field: 'x' }` inside a standalone `type: 'form'` component is the spec
13+
* form-VIEW vocabulary (an object-field reference) — a different layer from
14+
* the runtime vocabulary this CLI validates (`name` = form data path). The
15+
* zod error for it ("name: expected string, received undefined") reads as an
16+
* instruction to bolt a `name` on, which converts the metadata WRONGLY: an
17+
* agent following it loses the object-schema merge the spec shape stands for.
18+
* This detector lets the CLI say what actually happened and what the real fix
19+
* is. The error message is the prompt the next agent executes — it has to
20+
* point at the boundary, not at the symptom.
21+
*
22+
* Scope is deliberately exact: only `fields` arrays hanging DIRECTLY off a
23+
* `type: 'form'` node. Section fields (`type: 'object-form'` → `sections[]`)
24+
* legitimately accept the spec shape — `normalizeSectionField` translates
25+
* them — and a runtime FormField's `field` key legitimately holds the
26+
* resolved metadata OBJECT, so only a STRING `field` marks the spec shape.
27+
*/
28+
29+
export interface SpecVocabularyFinding {
30+
/** JSON-ish path to the entry, e.g. `children[0].fields[2]`. */
31+
path: string;
32+
/** The spec `field` value (the referenced object field). */
33+
field: string;
34+
/** Set when the entry ALSO carries a runtime `name` (mixed vocabularies). */
35+
mixedName?: string;
36+
}
37+
38+
export function findSpecVocabularyFormFields(
39+
node: unknown,
40+
path = '',
41+
): SpecVocabularyFinding[] {
42+
if (Array.isArray(node)) {
43+
return node.flatMap((child, i) => findSpecVocabularyFormFields(child, `${path}[${i}]`));
44+
}
45+
if (node === null || typeof node !== 'object') return [];
46+
47+
const record = node as Record<string, unknown>;
48+
const findings: SpecVocabularyFinding[] = [];
49+
50+
if (record.type === 'form' && Array.isArray(record.fields)) {
51+
record.fields.forEach((f, i) => {
52+
if (f && typeof f === 'object' && typeof (f as any).field === 'string') {
53+
const name = (f as any).name;
54+
findings.push({
55+
path: `${path ? `${path}.` : ''}fields[${i}]`,
56+
field: (f as any).field,
57+
...(typeof name === 'string' ? { mixedName: name } : {}),
58+
});
59+
}
60+
});
61+
}
62+
63+
for (const [key, value] of Object.entries(record)) {
64+
// The entries themselves were just inspected; don't re-walk them as
65+
// generic children or each finding would double-report.
66+
if (record.type === 'form' && key === 'fields') continue;
67+
findings.push(
68+
...findSpecVocabularyFormFields(value, path ? `${path}.${key}` : key),
69+
);
70+
}
71+
return findings;
72+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
9+
/**
10+
* Spec-vocabulary boundary in the standalone form renderer (#3090).
11+
*
12+
* `{ field: 'x' }` is the spec form-VIEW vocabulary — an object-field
13+
* reference the standalone renderer cannot resolve (no object schema). Writing
14+
* the first test here against the unfixed renderer proved the pre-#3090
15+
* behavior was even worse than the assumed silent drop: the entry slipped past
16+
* the `f?.name` guards into a react-hook-form Controller with
17+
* `name === undefined` and CRASHED the whole form on `name.split('.')`,
18+
* with nothing naming the culprit entry. The renderer now partitions such
19+
* entries out (the rest of the form renders) and surfaces them: an inline
20+
* alert naming the fields, and a console.error whose text doubles as the fix
21+
* instruction.
22+
*/
23+
24+
import React from 'react';
25+
import { describe, it, expect, vi, afterEach } from 'vitest';
26+
import { render, screen, cleanup } from '@testing-library/react';
27+
import { ComponentRegistry } from '@object-ui/core';
28+
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
29+
// cold transform is billed to `hookTimeout`. See
30+
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
31+
import '../../../renderers';
32+
33+
afterEach(() => {
34+
cleanup();
35+
vi.restoreAllMocks();
36+
});
37+
38+
function renderForm(fields: any[]) {
39+
const Form = ComponentRegistry.get('form')!;
40+
return render(
41+
<Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />,
42+
);
43+
}
44+
45+
describe('form renderer — spec-vocabulary entries surface loudly (#3090)', () => {
46+
it('renders an inline alert naming the unrenderable fields, and still renders the rest', () => {
47+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
48+
49+
renderForm([
50+
{ name: 'subject', label: 'Subject', type: 'input' },
51+
{ field: 'owner', required: true }, // spec form-view shape — unrenderable here
52+
]);
53+
54+
// The valid field still renders…
55+
expect(document.body.querySelector('[data-field="subject"]')).toBeTruthy();
56+
// …the spec-shaped one is called out instead of silently dropped.
57+
const alert = screen.getByTestId('form-spec-vocabulary-error');
58+
expect(alert.textContent).toContain('owner');
59+
expect(alert.textContent).toContain('{ name:');
60+
61+
// The console message is the fix instruction an agent will follow.
62+
const said = error.mock.calls.map((c) => String(c[0])).join('\n');
63+
expect(said).toContain("{ field: 'owner' }");
64+
expect(said).toContain('Rename the key to `name`');
65+
});
66+
67+
it('renders no alert and logs nothing for a well-formed standalone form', () => {
68+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
69+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
70+
71+
renderForm([{ name: 'subject', label: 'Subject', type: 'input' }]);
72+
73+
expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull();
74+
const noise = [...error.mock.calls, ...warn.mock.calls]
75+
.map((c) => String(c[0]))
76+
.filter((m) => m.includes('[object-ui]'));
77+
expect(noise).toEqual([]);
78+
});
79+
80+
it('warns (but renders by `name`) when an entry mixes both vocabularies', () => {
81+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
82+
83+
renderForm([{ name: 'subject', field: 'subject_line', label: 'Subject', type: 'input' }]);
84+
85+
// `name` wins — the field renders, no destructive alert…
86+
expect(document.body.querySelector('[data-field="subject"]')).toBeTruthy();
87+
expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull();
88+
// …but the ignored spec key is called out.
89+
const said = warn.mock.calls.map((c) => String(c[0])).join('\n');
90+
expect(said).toContain('mixes vocabularies');
91+
expect(said).toContain("{ field: 'subject_line' }");
92+
});
93+
94+
it('does NOT flag the runtime metadata-object `field` slot (the #3090 pun)', () => {
95+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
96+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
97+
98+
// Object-bound paths stash the resolved field-metadata OBJECT under
99+
// `field` — same key, different layer. Only a STRING `field` marks the
100+
// spec authored shape; the object slot must pass without noise.
101+
renderForm([
102+
{ name: 'amount', type: 'input', field: { type: 'currency', scale: 2 } },
103+
]);
104+
105+
expect(document.body.querySelector('[data-field="amount"]')).toBeTruthy();
106+
expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull();
107+
const noise = [...error.mock.calls, ...warn.mock.calls]
108+
.map((c) => String(c[0]))
109+
.filter((m) => m.includes('[object-ui]'));
110+
expect(noise).toEqual([]);
111+
});
112+
});

0 commit comments

Comments
 (0)