Skip to content

Commit 0c19b04

Browse files
committed
feat: add template-no-duplicate-form-names — flag duplicate form-control name within a form
1 parent 24882a3 commit 0c19b04

8 files changed

Lines changed: 1209 additions & 164 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
463463

464464
| Name                                                 | Description | 💼 | 🔧 | 💡 |
465465
| :------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------- | :- | :- | :- |
466+
| [template-no-duplicate-form-names](docs/rules/template-no-duplicate-form-names.md) | disallow duplicate form control names within the same form | | | |
466467
| [template-no-extra-mut-helper-argument](docs/rules/template-no-extra-mut-helper-argument.md) | disallow passing more than one argument to the mut helper | 📋 | | |
467468
| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
468469
| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | |
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# ember/template-no-duplicate-form-names
2+
3+
<!-- end auto-generated rule header -->
4+
5+
This rule disallows two form controls sharing the same `name` attribute
6+
within the same `<form>` (or within the template root, if no `<form>`
7+
wraps the controls).
8+
9+
Duplicate names break form serialization: both values are emitted into
10+
the entry list, and server-side code that expects a single value typically
11+
reads only one — often not the one the author intended.
12+
13+
Three categories are exempt from the duplicate check:
14+
15+
- **Non-submitting controls** (`<input type="button">`, `<input type="reset">`,
16+
`<button type="button">`, `<button type="reset">`) are skipped entirely.
17+
Per [HTML §4.10.21.4](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set)
18+
they do not contribute to the form data, so their `name` can't collide
19+
with anything.
20+
- **Radio groups** (`<input type="radio">`) share a `name` with same-type
21+
siblings to express mutual exclusion — exactly one contributes per
22+
submission.
23+
- **Submit-like controls** may share a `name` across any mix of
24+
`<input type="submit">`, `<input type="image">`, and `<button>` (bare
25+
`<button>` defaults to `type="submit"` per HTML §4.10.9). Only one
26+
submit-like control contributes to the form-data entry list per
27+
submission — the one the user activated — so same-name is unambiguous.
28+
29+
## Examples
30+
31+
This rule **forbids** the following:
32+
33+
```hbs
34+
<form>
35+
<input name='email' />
36+
<input name='email' />
37+
</form>
38+
39+
<form>
40+
<input type='text' name='field' />
41+
<textarea name='field'></textarea>
42+
</form>
43+
```
44+
45+
This rule **allows** the following:
46+
47+
```hbs
48+
<form>
49+
<input type='radio' name='color' value='red' />
50+
<input type='radio' name='color' value='blue' />
51+
<input type='submit' name='action' value='save' />
52+
<input type='submit' name='action' value='publish' />
53+
</form>
54+
```
55+
56+
Controls with the HTML `disabled` attribute are ignored — per
57+
[HTML §4.10.21.4](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set)
58+
they do not contribute to the form-data entry list and cannot collide.
59+
60+
Controls with the HTML `hidden` attribute are **not** exempt: `hidden`
61+
affects rendering, not form submission. A `hidden` control still submits
62+
its `name`/`value`, so a duplicate name involving a hidden control is a
63+
real collision.
64+
65+
## Limitations
66+
67+
- Controls associated with a form via the `form="id"` attribute (rather than
68+
by nesting inside a `<form>` element) are not tracked by this rule. Only
69+
controls that are descendants of a `<form>` element are scoped to that form.
70+
- `name` values via mustache (`name={{this.fieldName}}`) are skipped — we
71+
cannot know the value at lint time.
72+
- The `name[]` PHP-style array pattern is treated as an ordinary name; if
73+
you declare two `name="x[]"` controls in the same form this rule will
74+
still flag them. Support for array brackets may be added later.
75+
- The `<input type="hidden">` + `<input type="checkbox">` default-value
76+
pattern (which is permitted in HTML) is not recognized — the pair will
77+
be flagged if you use it. Rename the hidden input if you hit this.
78+
79+
## References
80+
81+
- [HTML spec: Form submission](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm)
82+
- Adapted from [`html-validate`'s `form-dup-name`](https://html-validate.org/rules/form-dup-name.html) (MIT).
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
'use strict';
2+
3+
// See html-validate (https://html-validate.org/rules/form-dup-name.html) for the peer rule concept.
4+
//
5+
// Simplifications vs. upstream for v1: we don't support `name[]` array syntax,
6+
// the <input type="hidden"> + <input type="checkbox"> default-value pattern,
7+
// or the full form-associated element registry. Scope is <input>/<select>/
8+
// <textarea>/<button>/<output>.
9+
//
10+
// Types that do not contribute to the form-data entry list (per HTML spec
11+
// §4.10.21.4) are skipped entirely — no name collision is possible because
12+
// they never submit. This covers <input type="button"|"reset"> and
13+
// <button type="button"|"reset">.
14+
//
15+
// Types whose duplicate-name pattern is legitimate are tracked but allowed to
16+
// share a name within their "share category":
17+
// - radio group: multiple <input type=radio> share a name by design (one
18+
// selected at a time);
19+
// - submit-like controls: at most one submit-like control contributes per
20+
// submission, so any mix of <input type=submit>, <input type=image>, and
21+
// <button [type=submit]> can share a name.
22+
// Derived from aria-query's button-role mapping; see `getShareCategory` below.
23+
24+
const { elementRoles } = require('aria-query');
25+
26+
const FORM_CONTROL_TAGS = new Set(['input', 'select', 'textarea', 'button', 'output']);
27+
const NON_SUBMITTING_TYPES = new Set(['button', 'reset']);
28+
29+
// Submit-like <input> types — derived from aria-query's `button`-role mapping
30+
// (input[type=submit|image|button|reset] all map to role=button), minus the
31+
// non-submitting types. At most ONE submit-like control contributes to the
32+
// form-data entry list per submission (the one the user clicks), so any mix
33+
// of these can legitimately share a name.
34+
//
35+
// "Radio group" semantics are orthogonal: multiple <input type=radio> share a
36+
// name because selection is mutually exclusive, and exactly one contributes
37+
// its value. So radios get their own category.
38+
const SUBMIT_LIKE_INPUT_TYPES = buildSubmitLikeInputTypes();
39+
40+
function buildSubmitLikeInputTypes() {
41+
const result = new Set();
42+
for (const [schema, rolesSet] of elementRoles) {
43+
if (schema.name !== 'input') {
44+
continue;
45+
}
46+
if (!rolesSet.includes('button')) {
47+
continue;
48+
}
49+
const typeAttr = (schema.attributes || []).find((a) => a.name === 'type');
50+
if (!typeAttr || typeof typeAttr.value !== 'string') {
51+
continue;
52+
}
53+
if (NON_SUBMITTING_TYPES.has(typeAttr.value)) {
54+
continue;
55+
}
56+
result.add(typeAttr.value);
57+
}
58+
return result;
59+
}
60+
61+
// Returns the "share category" for a control type: entries with the same
62+
// non-null category can legitimately share a name.
63+
// - 'radio': radio-group semantics (one selected at a time)
64+
// - 'submit-like': submit-control semantics (one triggers submission)
65+
// - null: not shareable; any same-name collision is a real duplicate
66+
function getShareCategory(tag, type) {
67+
if (tag === 'input' && type === 'radio') {
68+
return 'radio';
69+
}
70+
if (tag === 'button') {
71+
// Bare <button> defaults to type=submit; <button type=submit> too.
72+
if (type === 'submit') {
73+
return 'submit-like';
74+
}
75+
return null;
76+
}
77+
if (tag === 'input' && SUBMIT_LIKE_INPUT_TYPES.has(type)) {
78+
return 'submit-like';
79+
}
80+
return null;
81+
}
82+
83+
function findAttr(node, name) {
84+
return node.attributes?.find((attr) => attr.name === name);
85+
}
86+
87+
function getStaticAttrValue(node, name) {
88+
const attr = findAttr(node, name);
89+
if (!attr || !attr.value) {
90+
return { kind: attr ? 'empty' : 'absent', value: '' };
91+
}
92+
if (attr.value.type === 'GlimmerTextNode') {
93+
return { kind: 'static', value: attr.value.chars };
94+
}
95+
if (attr.value.type === 'GlimmerMustacheStatement' && attr.value.path) {
96+
if (attr.value.path.type === 'GlimmerStringLiteral') {
97+
return { kind: 'static', value: attr.value.path.value };
98+
}
99+
if (attr.value.path.type === 'GlimmerBooleanLiteral') {
100+
return { kind: 'static', value: String(attr.value.path.value) };
101+
}
102+
}
103+
return { kind: 'dynamic', value: '' };
104+
}
105+
106+
// HTML §4.10.18 — `<button>` and `<input>` with missing/invalid/unknown type
107+
// fall back to the default state ('submit' for <button>, 'text' for <input>).
108+
const BUTTON_TYPES = new Set(['submit', 'reset', 'button']);
109+
const INPUT_TYPES = new Set([
110+
'hidden',
111+
'text',
112+
'search',
113+
'tel',
114+
'url',
115+
'email',
116+
'password',
117+
'date',
118+
'month',
119+
'week',
120+
'time',
121+
'datetime-local',
122+
'number',
123+
'range',
124+
'color',
125+
'checkbox',
126+
'radio',
127+
'file',
128+
'submit',
129+
'image',
130+
'reset',
131+
'button',
132+
]);
133+
134+
function getControlType(node) {
135+
if (node.tag === 'button') {
136+
const t = getStaticAttrValue(node, 'type');
137+
if (t.kind === 'static') {
138+
return BUTTON_TYPES.has(t.value.toLowerCase()) ? t.value.toLowerCase() : 'submit';
139+
}
140+
if (t.kind === 'absent') {
141+
return 'submit';
142+
}
143+
// Dynamic or empty type (mustache / concat / valueless) — the runtime
144+
// value is unknown. Return a sentinel so the caller can skip duplicate-
145+
// name checks for this node rather than baking in a wrong default.
146+
return 'unknown';
147+
}
148+
if (node.tag === 'input') {
149+
const t = getStaticAttrValue(node, 'type');
150+
if (t.kind === 'static') {
151+
return INPUT_TYPES.has(t.value.toLowerCase()) ? t.value.toLowerCase() : 'text';
152+
}
153+
if (t.kind === 'absent' || t.kind === 'empty') {
154+
return 'text';
155+
}
156+
return 'unknown';
157+
}
158+
return node.tag;
159+
}
160+
161+
function findEnclosingFormOrRoot(node) {
162+
let current = node.parent;
163+
while (current) {
164+
if (current.type === 'GlimmerElementNode' && current.tag === 'form') {
165+
return current;
166+
}
167+
current = current.parent;
168+
}
169+
return null;
170+
}
171+
172+
const { getBranchPath, areMutuallyExclusive } = require('../utils/control-flow');
173+
174+
// Per HTML spec (§4.10.21.4 "Constructing the entry list"), only `disabled`
175+
// controls are skipped when building the form-data entry list. `hidden`
176+
// does NOT affect submission — a hidden control still contributes its name
177+
// and value. Duplicate-name collisions can therefore happen even when one
178+
// of the controls is `hidden`.
179+
//
180+
// `disabled={{false}}` (boolean-literal mustache) is carved out: Glimmer VM
181+
// normalizes boolean `false` to attribute removal at runtime (see
182+
// `SimpleDynamicAttribute.update` → `removeAttribute`), so the rendered DOM
183+
// has no `disabled` attribute and the control IS enabled. Matches the same
184+
// carve-out in `template-no-autofocus-attribute`. Other falsy-looking forms
185+
// — `disabled="false"` (static string), `disabled={{"false"}}` (string-
186+
// literal mustache) — still mean disabled per HTML boolean-attribute
187+
// semantics: presence = disabled regardless of value content.
188+
function isDisabled(node) {
189+
const attr = findAttr(node, 'disabled');
190+
if (!attr) {
191+
return false;
192+
}
193+
const value = attr.value;
194+
if (
195+
value &&
196+
value.type === 'GlimmerMustacheStatement' &&
197+
value.path &&
198+
value.path.type === 'GlimmerBooleanLiteral' &&
199+
value.path.value === false
200+
) {
201+
return false;
202+
}
203+
return true;
204+
}
205+
206+
/** @type {import('eslint').Rule.RuleModule} */
207+
module.exports = {
208+
meta: {
209+
type: 'problem',
210+
docs: {
211+
description: 'disallow duplicate form control names within the same form',
212+
category: 'Possible Errors',
213+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-form-names.md',
214+
templateMode: 'both',
215+
},
216+
schema: [],
217+
messages: {
218+
duplicate: 'Duplicate form control `name="{{name}}"` within the same form',
219+
},
220+
},
221+
222+
create(context) {
223+
// Per-form: Map<name, entries[]>. Each entry records { type, path } so we
224+
// can pairwise compare against subsequent occurrences — mutually exclusive
225+
// branches (different `program`/`inverse` subtrees of the same
226+
// `{{#if}}`/`{{#unless}}`) never both render, so their same-name
227+
// "collision" is a false positive.
228+
const nameMapByForm = new WeakMap();
229+
const rootMap = new Map();
230+
231+
function getMapForForm(formNode) {
232+
if (!formNode) {
233+
return rootMap;
234+
}
235+
let map = nameMapByForm.get(formNode);
236+
if (!map) {
237+
map = new Map();
238+
nameMapByForm.set(formNode, map);
239+
}
240+
return map;
241+
}
242+
243+
return {
244+
GlimmerElementNode(node) {
245+
if (!FORM_CONTROL_TAGS.has(node.tag)) {
246+
return;
247+
}
248+
if (isDisabled(node)) {
249+
return;
250+
}
251+
const nameInfo = getStaticAttrValue(node, 'name');
252+
if (nameInfo.kind !== 'static' || nameInfo.value === '') {
253+
return;
254+
}
255+
const name = nameInfo.value;
256+
const type = getControlType(node);
257+
// Dynamic type (`type={{this.kind}}` / concat) — we can't classify
258+
// the control's submission behavior. Skip duplicate-name collision
259+
// checks for this node rather than guessing; false negatives here
260+
// are safer than false positives on legitimate branches.
261+
if ((node.tag === 'input' || node.tag === 'button') && type === 'unknown') {
262+
return;
263+
}
264+
// Non-submitting controls contribute nothing to the form-data entry
265+
// list, so their `name` can't collide with anything.
266+
if ((node.tag === 'input' || node.tag === 'button') && NON_SUBMITTING_TYPES.has(type)) {
267+
return;
268+
}
269+
const form = findEnclosingFormOrRoot(node);
270+
const map = getMapForForm(form);
271+
const path = getBranchPath(node);
272+
273+
const entries = map.get(name);
274+
const currCategory = getShareCategory(node.tag, type);
275+
276+
if (!entries) {
277+
map.set(name, [{ tag: node.tag, type, path, category: currCategory }]);
278+
return;
279+
}
280+
281+
const collides = entries.some((prev) => {
282+
// Same share-category (radio group, or any mix of submit-like
283+
// controls) coexist legitimately — at most one contributes to the
284+
// form-data entry list per submission.
285+
if (currCategory !== null && currCategory === prev.category) {
286+
return false;
287+
}
288+
// Mutually exclusive control-flow branches never render together.
289+
if (areMutuallyExclusive(prev.path, path)) {
290+
return false;
291+
}
292+
return true;
293+
});
294+
295+
entries.push({ tag: node.tag, type, path, category: currCategory });
296+
297+
if (collides) {
298+
const nameAttr = findAttr(node, 'name');
299+
context.report({
300+
node: nameAttr || node,
301+
messageId: 'duplicate',
302+
data: { name },
303+
});
304+
}
305+
},
306+
};
307+
},
308+
};

0 commit comments

Comments
 (0)