|
| 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