From 82c9577cbe62ba184b3f973a42732306b59b1605 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:26:04 -0400 Subject: [PATCH 1/2] Extract rule: template-no-builtin-form-components --- README.md | 1 + .../template-no-builtin-form-components.md | 90 ++++++++++++++++++ .../template-no-builtin-form-components.js | 45 +++++++++ .../template-no-builtin-form-components.js | 94 +++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 docs/rules/template-no-builtin-form-components.md create mode 100644 lib/rules/template-no-builtin-form-components.js create mode 100644 tests/lib/rules/template-no-builtin-form-components.js diff --git a/README.md b/README.md index 908553fc75..ec19cb3f57 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-bare-strings](docs/rules/template-no-bare-strings.md) | disallow bare strings in templates (require translation/localization) | | | | | [template-no-bare-yield](docs/rules/template-no-bare-yield.md) | disallow templates whose only meaningful content is a bare {{yield}} | | | | | [template-no-block-params-for-html-elements](docs/rules/template-no-block-params-for-html-elements.md) | disallow block params on HTML elements | | | | +| [template-no-builtin-form-components](docs/rules/template-no-builtin-form-components.md) | disallow usage of built-in form components | | | | | [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | | | [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | | | [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | | diff --git a/docs/rules/template-no-builtin-form-components.md b/docs/rules/template-no-builtin-form-components.md new file mode 100644 index 0000000000..8dda72b667 --- /dev/null +++ b/docs/rules/template-no-builtin-form-components.md @@ -0,0 +1,90 @@ +# ember/template-no-builtin-form-components + + + +Disallow usage of Ember's built-in `` and ` +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +## Migration + +Many forms may be simplified by switching to a light one-way data approach. + +For example – vanilla JavaScript has everything we need to handle form data, de-sync it from our source data and collect all user input in a single object. + +```js +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class MyComponent extends Component { + @tracked userInput = {}; + + @action + handleInput(event) { + const formData = new FormData(event.currentTarget); + this.userInput = Object.fromEntries(formData.entries()); + } +} +``` + +```hbs +
+ +
+``` + +Another option would is to "control" the field's value by replacing the built-in form component with a native HTML element and binding an event listener to handle user input. + +In the following example the initial value of a field is controlled by a local tracked property, which is updated by an event listener. + +```js +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class MyComponent extends Component { + @tracked name; + + @action + updateName(event) { + this.name = event.target.value; + } +} +``` + +```hbs + +``` + +## Related Rules + +- [no-mut-helper](template-no-mut-helper.md) + +## References + +- [Ember Built-in Components](https://guides.emberjs.com/release/components/built-in-components/) +- [ember-template-lint no-builtin-form-components](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-builtin-form-components.md) diff --git a/lib/rules/template-no-builtin-form-components.js b/lib/rules/template-no-builtin-form-components.js new file mode 100644 index 0000000000..8d6c0f3c30 --- /dev/null +++ b/lib/rules/template-no-builtin-form-components.js @@ -0,0 +1,45 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'disallow usage of built-in form components', + category: 'Best Practices', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-builtin-form-components.md', + templateMode: 'both', + }, + fixable: null, + schema: [], + messages: { + noInput: + 'Do not use the `Input` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.', + noTextarea: + 'Do not use the `Textarea` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.', + }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-builtin-form-components.js', + docs: 'docs/rule/no-builtin-form-components.md', + tests: 'test/unit/rules/no-builtin-form-components-test.js', + }, + }, + + create(context) { + const MESSAGE_IDS = { + Input: 'noInput', + Textarea: 'noTextarea', + }; + + return { + GlimmerElementNode(node) { + const messageId = MESSAGE_IDS[node.tag]; + if (messageId) { + context.report({ + node, + messageId, + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-builtin-form-components.js b/tests/lib/rules/template-no-builtin-form-components.js new file mode 100644 index 0000000000..c694ec9006 --- /dev/null +++ b/tests/lib/rules/template-no-builtin-form-components.js @@ -0,0 +1,94 @@ +const rule = require('../../../lib/rules/template-no-builtin-form-components'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-builtin-form-components', rule, { + valid: [ + '', + '', + '', + '', + '', + ], + invalid: [ + { + code: '', + output: null, + errors: [ + { + messageId: 'noInput', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + messageId: 'noInput', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + messageId: 'noTextarea', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + messageId: 'noTextarea', + }, + ], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +hbsRuleTester.run('template-no-builtin-form-components', rule, { + valid: [ + '', + '', + '', + '', + ], + invalid: [ + { + code: '', + output: null, + errors: [ + { + message: + 'Do not use the `Input` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: + 'Do not use the `Textarea` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.', + }, + ], + }, + ], +}); From 29fe7f61f110f5d494dbb61db94d409dbb0f8f23 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:10:35 -0400 Subject: [PATCH 2/2] Account for imports and aliasing --- .../template-no-builtin-form-components.js | 55 ++++++- .../template-no-builtin-form-components.js | 155 +++++++++++++----- 2 files changed, 162 insertions(+), 48 deletions(-) diff --git a/lib/rules/template-no-builtin-form-components.js b/lib/rules/template-no-builtin-form-components.js index 8d6c0f3c30..fba7cb8467 100644 --- a/lib/rules/template-no-builtin-form-components.js +++ b/lib/rules/template-no-builtin-form-components.js @@ -30,14 +30,57 @@ module.exports = { Textarea: 'noTextarea', }; + const filename = context.filename ?? context.getFilename(); + const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts'); + + // local name → original name ('Input' | 'Textarea') + // Only populated in GJS/GTS files via ImportDeclaration + const importedComponents = new Map(); + return { + ImportDeclaration(node) { + if (node.source.value === '@ember/component') { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier') { + const original = specifier.imported.name; + if (original === 'Input' || original === 'Textarea') { + importedComponents.set(specifier.local.name, original); + } + } + } + } + }, + GlimmerElementNode(node) { - const messageId = MESSAGE_IDS[node.tag]; - if (messageId) { - context.report({ - node, - messageId, - }); + const tag = node.tag; + if (isStrictMode) { + // In GJS/GTS: only flag if explicitly imported from @ember/component + const original = importedComponents.get(tag); + if (original) { + context.report({ node, messageId: MESSAGE_IDS[original] }); + } + } else { + // In HBS: flag by canonical name (no import context available) + const messageId = MESSAGE_IDS[tag]; + if (messageId) { + context.report({ node, messageId }); + } + } + }, + + // Catch usages as a value: {{yield Input}}, (component Input), @field={{Input}}, etc. + GlimmerPathExpression(node) { + const name = node.original; + if (isStrictMode) { + const original = importedComponents.get(name); + if (original) { + context.report({ node, messageId: MESSAGE_IDS[original] }); + } + } else { + const messageId = MESSAGE_IDS[name]; + if (messageId) { + context.report({ node, messageId }); + } } }, }; diff --git a/tests/lib/rules/template-no-builtin-form-components.js b/tests/lib/rules/template-no-builtin-form-components.js index c694ec9006..12f2e3ac2e 100644 --- a/tests/lib/rules/template-no-builtin-form-components.js +++ b/tests/lib/rules/template-no-builtin-form-components.js @@ -8,48 +8,97 @@ const ruleTester = new RuleTester({ ruleTester.run('template-no-builtin-form-components', rule, { valid: [ - '', - '', - '', - '', - '', + // Native HTML elements are always fine + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + + // In GJS without an import from @ember/component, /' }, + + // Importing from a different source is fine + { + filename: 'test.gjs', + code: "import { Input } from './my-components'; ", + }, + { + filename: 'test.gjs', + code: "import { Textarea } from './my-components'; ", + }, ], invalid: [ { - code: '', + filename: 'test.gjs', + code: "import { Input } from '@ember/component'; ", + output: null, + errors: [{ messageId: 'noInput' }], + }, + { + filename: 'test.gjs', + code: 'import { Input } from \'@ember/component\'; ', + output: null, + errors: [{ messageId: 'noInput' }], + }, + { + // Aliased import must still be flagged + filename: 'test.gjs', + code: "import { Input as EmberInput } from '@ember/component'; ", output: null, - errors: [ - { - messageId: 'noInput', - }, - ], + errors: [{ messageId: 'noInput' }], }, { - code: '', + filename: 'test.gjs', + code: "import { Textarea } from '@ember/component'; ", output: null, - errors: [ - { - messageId: 'noInput', - }, - ], + errors: [{ messageId: 'noTextarea' }], }, { - code: '', + filename: 'test.gjs', + code: "import { Textarea } from '@ember/component'; ", output: null, - errors: [ - { - messageId: 'noTextarea', - }, - ], + errors: [{ messageId: 'noTextarea' }], }, { - code: '', + // Aliased Textarea import must still be flagged + filename: 'test.gjs', + code: "import { Textarea as EmberTextarea } from '@ember/component'; ", output: null, - errors: [ - { - messageId: 'noTextarea', - }, - ], + errors: [{ messageId: 'noTextarea' }], + }, + // Yielded as a value + { + filename: 'test.gjs', + code: "import { Input } from '@ember/component'; ", + output: null, + errors: [{ messageId: 'noInput' }], + }, + { + filename: 'test.gjs', + code: "import { Input as EmberInput } from '@ember/component'; ", + output: null, + errors: [{ messageId: 'noInput' }], + }, + { + filename: 'test.gjs', + code: "import { Textarea } from '@ember/component'; ", + output: null, + errors: [{ messageId: 'noTextarea' }], + }, + // Used in helpers / passed as argument + { + filename: 'test.gjs', + code: "import { Input } from '@ember/component'; ", + output: null, + errors: [{ messageId: 'noInput' }], + }, + { + filename: 'test.gjs', + code: "import { Input } from '@ember/component'; ", + output: null, + errors: [{ messageId: 'noInput' }], }, ], }); @@ -62,7 +111,7 @@ const hbsRuleTester = new RuleTester({ }, }); -hbsRuleTester.run('template-no-builtin-form-components', rule, { +hbsRuleTester.run('template-no-builtin-form-components (hbs)', rule, { valid: [ '', '', @@ -73,22 +122,44 @@ hbsRuleTester.run('template-no-builtin-form-components', rule, { { code: '', output: null, - errors: [ - { - message: - 'Do not use the `Input` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.', - }, - ], + errors: [{ messageId: 'noInput' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInput' }], }, { code: '', output: null, - errors: [ - { - message: - 'Do not use the `Textarea` component. Built-in form components use two-way binding to mutate values. Instead, refactor to use a native HTML element.', - }, - ], + errors: [{ messageId: 'noTextarea' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noTextarea' }], + }, + // Yielded as a value + { + code: '{{yield Input}}', + output: null, + errors: [{ messageId: 'noInput' }], + }, + { + code: '{{yield Textarea}}', + output: null, + errors: [{ messageId: 'noTextarea' }], + }, + // Used in helpers / passed as argument + { + code: '', + output: null, + errors: [{ messageId: 'noInput' }], + }, + { + code: '{{component Input}}', + output: null, + errors: [{ messageId: 'noInput' }], }, ], });