From 0c19b042f07c1cb43db80aa905098795287c6304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 27 Apr 2026 21:32:10 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20template-no-duplicate-form-na?= =?UTF-8?q?mes=20=E2=80=94=20flag=20duplicate=20form-control=20name=20with?= =?UTF-8?q?in=20a=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + .../rules/template-no-duplicate-form-names.md | 82 +++++ lib/rules/template-no-duplicate-form-names.js | 308 ++++++++++++++++++ lib/rules/template-no-duplicate-id.js | 83 ++--- ...template-no-duplicate-landmark-elements.js | 221 +++++++------ lib/utils/control-flow.js | 222 +++++++++++++ .../rules/template-no-duplicate-form-names.js | 151 +++++++++ tests/lib/utils/control-flow-test.js | 305 +++++++++++++++++ 8 files changed, 1209 insertions(+), 164 deletions(-) create mode 100644 docs/rules/template-no-duplicate-form-names.md create mode 100644 lib/rules/template-no-duplicate-form-names.js create mode 100644 lib/utils/control-flow.js create mode 100644 tests/lib/rules/template-no-duplicate-form-names.js create mode 100644 tests/lib/utils/control-flow-test.js diff --git a/README.md b/README.md index dafcfd0640..fd236a6103 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le | Name                                                 | Description | 💼 | 🔧 | 💡 | | :------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------- | :- | :- | :- | +| [template-no-duplicate-form-names](docs/rules/template-no-duplicate-form-names.md) | disallow duplicate form control names within the same form | | | | | [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 | 📋 | | | | [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | | | [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | | diff --git a/docs/rules/template-no-duplicate-form-names.md b/docs/rules/template-no-duplicate-form-names.md new file mode 100644 index 0000000000..83672f42be --- /dev/null +++ b/docs/rules/template-no-duplicate-form-names.md @@ -0,0 +1,82 @@ +# ember/template-no-duplicate-form-names + + + +This rule disallows two form controls sharing the same `name` attribute +within the same `
` (or within the template root, if no `` +wraps the controls). + +Duplicate names break form serialization: both values are emitted into +the entry list, and server-side code that expects a single value typically +reads only one — often not the one the author intended. + +Three categories are exempt from the duplicate check: + +- **Non-submitting controls** (``, ``, + `
', '
', + // Valueless `type` on ', // Non-submitting types (button, reset) don't contribute to form data; their // `name` is skipped entirely, so any combination is fine. '
', From 0198d68a527d12f3d7ef57f13021d93ed64d23c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 28 Apr 2026 08:39:44 +0200 Subject: [PATCH 3/5] fix: flag duplicate names on disabled controls too --- lib/rules/template-no-duplicate-form-names.js | 34 ------------------- .../rules/template-no-duplicate-form-names.js | 17 ++++++---- 2 files changed, 10 insertions(+), 41 deletions(-) diff --git a/lib/rules/template-no-duplicate-form-names.js b/lib/rules/template-no-duplicate-form-names.js index dce1f5f9f9..cf3a175f0d 100644 --- a/lib/rules/template-no-duplicate-form-names.js +++ b/lib/rules/template-no-duplicate-form-names.js @@ -173,37 +173,6 @@ function findEnclosingFormOrRoot(node) { const { getBranchPath, areMutuallyExclusive } = require('../utils/control-flow'); -// Per HTML spec (§4.10.21.4 "Constructing the entry list"), only `disabled` -// controls are skipped when building the form-data entry list. `hidden` -// does NOT affect submission — a hidden control still contributes its name -// and value. Duplicate-name collisions can therefore happen even when one -// of the controls is `hidden`. -// -// `disabled={{false}}` (boolean-literal mustache) is carved out: Glimmer VM -// normalizes boolean `false` to attribute removal at runtime (see -// `SimpleDynamicAttribute.update` → `removeAttribute`), so the rendered DOM -// has no `disabled` attribute and the control IS enabled. Matches the same -// carve-out in `template-no-autofocus-attribute`. Other falsy-looking forms -// — `disabled="false"` (static string), `disabled={{"false"}}` (string- -// literal mustache) — still mean disabled per HTML boolean-attribute -// semantics: presence = disabled regardless of value content. -function isDisabled(node) { - const attr = findAttr(node, 'disabled'); - if (!attr) { - return false; - } - const value = attr.value; - if ( - value && - value.type === 'GlimmerMustacheStatement' && - value.path && - value.path.type === 'GlimmerBooleanLiteral' && - value.path.value === false - ) { - return false; - } - return true; -} /** @type {import('eslint').Rule.RuleModule} */ module.exports = { @@ -247,9 +216,6 @@ module.exports = { if (!FORM_CONTROL_TAGS.has(node.tag)) { return; } - if (isDisabled(node)) { - return; - } const nameInfo = getStaticAttrValue(node, 'name'); if (nameInfo.kind !== 'static' || nameInfo.value === '') { return; diff --git a/tests/lib/rules/template-no-duplicate-form-names.js b/tests/lib/rules/template-no-duplicate-form-names.js index dc459f8f9d..8e6e2ed04d 100644 --- a/tests/lib/rules/template-no-duplicate-form-names.js +++ b/tests/lib/rules/template-no-duplicate-form-names.js @@ -38,9 +38,6 @@ const validHbs = [ '
', // Same name but in different forms — fine. '
', - // Disabled control is ignored — it does not contribute to form data. - '
', - '
', // No name attribute — skip. '
', // Empty name — skip. @@ -85,10 +82,16 @@ const invalidHbs = [ code: '
', errors: [{ message: err('a') }], }, - // `disabled={{false}}` renders no `disabled` attribute at runtime - // (Glimmer VM normalizes boolean false to attribute removal) — the - // control IS enabled and contributes to form data, so the duplicate - // name collides with the enabled sibling. + // Disabled controls are still flagged — disabled state is dynamic and the + // duplicate name is a structural problem regardless. + { + code: '
', + errors: [{ message: err('a') }], + }, + { + code: '
', + errors: [{ message: err('a') }], + }, { code: '
', errors: [{ message: err('a') }], From a922d73c26aed8f7de5cf8dd358e7582e82acfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 28 Apr 2026 08:51:23 +0200 Subject: [PATCH 4/5] fix: prettier formatting --- lib/rules/template-no-duplicate-form-names.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/rules/template-no-duplicate-form-names.js b/lib/rules/template-no-duplicate-form-names.js index cf3a175f0d..2d468fbcc3 100644 --- a/lib/rules/template-no-duplicate-form-names.js +++ b/lib/rules/template-no-duplicate-form-names.js @@ -173,7 +173,6 @@ function findEnclosingFormOrRoot(node) { const { getBranchPath, areMutuallyExclusive } = require('../utils/control-flow'); - /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { From 7880bba785e9c3bbac91bc514d42c8ce2f93939d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 28 Apr 2026 12:02:09 +0200 Subject: [PATCH 5/5] docs(control-flow): explain seenStack push rationale (matches upstream ember-template-lint design) --- lib/utils/control-flow.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/utils/control-flow.js b/lib/utils/control-flow.js index f50dcf9831..5b7a263982 100644 --- a/lib/utils/control-flow.js +++ b/lib/utils/control-flow.js @@ -185,6 +185,14 @@ function createConditionalScope() { conditionalStack.at(-1).add(key); } } else { + // Top-level conditional ended — its accumulated keys are now + // unconditionally "seen" at this scope. Push as a separate frame + // (rather than merging into seenStack[0]) to mirror upstream + // ember-template-lint's ConditionalScope (PR #1606) and keep + // per-conditional grouping for future debugging hooks. Functionally + // equivalent to a single merged Set for `has()` purposes; growth is + // O(top-level conditionals per template), which is bounded by source + // size. seenStack.push(keys); } },