diff --git a/README.md b/README.md index 6d9d1d44a1..195d5b68ea 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-array-prototype-extensions](docs/rules/template-no-array-prototype-extensions.md) | disallow usage of Ember Array prototype extensions | | | | | [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-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-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | | | [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | | diff --git a/docs/rules/template-no-chained-this.md b/docs/rules/template-no-chained-this.md new file mode 100644 index 0000000000..7f39e1707c --- /dev/null +++ b/docs/rules/template-no-chained-this.md @@ -0,0 +1,64 @@ +# ember/template-no-chained-this + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Disallow chained property access on `this`. + +Accessing deeply nested properties through `this` (like `this.user.name`) in templates makes components harder to refactor and test. It also creates tight coupling between the template and the component's internal structure. Use local variables or computed properties instead. + +## Rule Details + +This rule disallows chaining property access on `this` in templates (e.g., `this.foo.bar`). + +## Examples + +### Incorrect ❌ + +```gjs + + {{this.user.name}} + +``` + +```gjs + + {{this.model.user.firstName}} + +``` + +```gjs + + {{this.data.items.length}} + +``` + +### Correct ✅ + +```gjs + + {{this.userName}} + +``` + +```gjs + + {{get this.user "name"}} + +``` + +```gjs + + {{userName}} + +``` + +## Related Rules + +- [template-no-implicit-this](./template-no-implicit-this.md) + +## References + +- [Ember Best Practices - Component Design](https://guides.emberjs.com/release/components/) +- [eslint-plugin-ember template-no-this-in-template-only-components](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-this-in-template-only-components.md) diff --git a/lib/rules/template-no-chained-this.js b/lib/rules/template-no-chained-this.js new file mode 100644 index 0000000000..54893c01dd --- /dev/null +++ b/lib/rules/template-no-chained-this.js @@ -0,0 +1,53 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'disallow redundant `this.this` in templates', + category: 'Best Practices', + strictGjs: true, + strictGts: true, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-chained-this.md', + }, + fixable: 'code', + schema: [], + messages: { + noChainedThis: + 'this.this.* is not allowed in templates. This is likely a mistake — remove the redundant this.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode; + + return { + GlimmerPathExpression(node) { + const text = sourceCode.getText(node); + if (text.startsWith('this.this.')) { + // Don't autofix if this PathExpression is the path of a BlockStatement, + // because the closing tag wouldn't be updated and the template would break. + const isBlockPath = + node.parent?.type === 'GlimmerBlockStatement' && node.parent.path === node; + + context.report({ + node, + messageId: 'noChainedThis', + fix: isBlockPath + ? undefined + : (fixer) => { + return fixer.replaceText(node, text.replace('this.this.', 'this.')); + }, + }); + } + }, + GlimmerElementNode(node) { + if (node.tag?.startsWith('this.this.')) { + context.report({ + node, + messageId: 'noChainedThis', + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-chained-this.js b/tests/lib/rules/template-no-chained-this.js new file mode 100644 index 0000000000..a5f387d990 --- /dev/null +++ b/tests/lib/rules/template-no-chained-this.js @@ -0,0 +1,53 @@ +const rule = require('../../../lib/rules/template-no-chained-this'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-chained-this', rule, { + valid: [ + '{{this.value}}', + '{{this.thisvalue}}', + '{{@argName}}', + '{{this.user.name}}', + '', + '{{component this.dynamicComponent}}', + ], + + invalid: [ + { + code: '{{this.this.value}}', + output: '{{this.value}}', + errors: [{ messageId: 'noChainedThis' }], + }, + { + code: '{{helper value=this.this.foo}}', + output: '{{helper value=this.foo}}', + errors: [{ messageId: 'noChainedThis' }], + }, + { + code: '{{#if this.this.condition}}true{{/if}}', + output: '{{#if this.condition}}true{{/if}}', + errors: [{ messageId: 'noChainedThis' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noChainedThis' }], + }, + + // Test cases ported from ember-template-lint + { + code: '{{#this.this.value}}woo{{/this.this.value}}', + output: null, + errors: [{ messageId: 'noChainedThis' }], + }, + { + code: '{{component this.this.dynamicComponent}}', + output: '{{component this.dynamicComponent}}', + errors: [{ messageId: 'noChainedThis' }], + }, + ], +});