Repository: ember-cli/eslint-plugin-ember (js)

Issue:
New Octane Rule: `no-component-lifecycle-hooks`
Pulled from https://github.com/ember-cli/eslint-plugin-ember/issues/533 which proposes a new set of rules for Octane related best practices

Disallow "Classic" Ember Component lifecycle hooks. Prefer using `@ember/render-modifiers` or custom functional modifiers.

List of Component lifecycle hooks pulled from [here](https://guides.emberjs.com/release/components/the-component-lifecycle/#toc_order-of-lifecycle-hooks-called)

```js
// ### Good
export default class MyComponent extends Component {
  init() { ... }
  willDestroy() { ... }
}

export default Component.extend({
  init() { ... }
  willDestroy() { ... }
});

// ### Bad
export default class MyComponent extends Component {
  didDestroyElement() {}
  didInsertElement() {}
  didReceiveAttrs() {}
  didRender() {}
  didUpdate() {}
  didUpdateAttrs() {}
  willClearRender() {}
  willDestroyElement() {}
  willRender() {}
}

export default Component.extend({
  didDestroyElement() {},
  didInsertElement() {},
  didReceiveAttrs() {},
  didRender() {},
  didUpdate() {},
  didUpdateAttrs() {},
  willClearRender() {},
  willDestroyElement() {},
  willRender() {},
});
```

If anyone would like to implement this please comment that you'll take it and work away.

Pull request:
Add new rule `no-component-lifecycle-hooks` (included in `octane` config)
Closes #540.

Patch:
diff --git a/README.md b/README.md
index 9d547193..992466ff 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,7 @@ Each rule has emojis denoting what configuration it belongs to and/or a :wrench:
 | :car: | [no-actions-hash](./docs/rules/no-actions-hash.md) | disallow the actions hash in components, controllers, and routes |
 | :car: | [no-classic-classes](./docs/rules/no-classic-classes.md) | disallow "classic" classes in favor of native JS classes |
 | :car: | [no-classic-components](./docs/rules/no-classic-components.md) | enforce using Glimmer components |
+| :car: | [no-component-lifecycle-hooks](./docs/rules/no-component-lifecycle-hooks.md) | disallow usage of "classic" ember component lifecycle hooks. Render modifiers or custom functional modifiers should be used instead. |
 | :car: | [no-computed-properties-in-native-classes](./docs/rules/no-computed-properties-in-native-classes.md) | disallow using computed properties in native classes |
 | :car: | [require-tagless-components](./docs/rules/require-tagless-components.md) | disallow using the wrapper element of a component |
 
diff --git a/docs/rules/no-component-lifecycle-hooks.md b/docs/rules/no-component-lifecycle-hooks.md
new file mode 100644
index 00000000..9f87ef8e
--- /dev/null
+++ b/docs/rules/no-component-lifecycle-hooks.md
@@ -0,0 +1,77 @@
+# no-component-lifecycle-hooks
+
+Disallow usage of "classic" ember component lifecycle hooks.
+
+## Rule Details
+
+As most component lifecycle hooks are gone in glimmer components, this rule aims to:
+
+- remind the developer that those hooks no longer exist in glimmer components
+- encourage migrating away from those hooks in classic ember components
+
+Effectively, this rule disallows following lifecycle hooks in components:
+
+- `didDestroyElement`
+- `didInsertElement`
+- `didReceiveAttrs`
+- `didRender`
+- `didUpdate`
+- `didUpdateAttrs`
+- `willClearRender`
+- `willDestroyElement`
+- `willRender`
+
+Custom functional modifiers or @ember/render-modifiers should be used instead.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```js
+export default class MyComponent extends Component {
+  didDestroyElement() {}
+  didInsertElement() {}
+  didReceiveAttrs() {}
+  didRender() {}
+  didUpdate() {}
+  didUpdateAttrs() {}
+  willClearRender() {}
+  willDestroyElement() {}
+  willRender() {}
+}
+```
+
+```js
+export default Component.extend({
+  didDestroyElement() {},
+  didInsertElement() {},
+  didReceiveAttrs() {},
+  didRender() {},
+  didUpdate() {},
+  didUpdateAttrs() {},
+  willClearRender() {},
+  willDestroyElement() {},
+  willRender() {}
+});
+```
+
+Examples of **correct** code for this rule:
+
+```js
+export default class MyComponent extends Component {
+  init() {}
+  willDestroy() {}
+}
+```
+
+```js
+export default Component.extend({
+  init() {},
+  willDestroy() {}
+});
+```
+
+## Further Reading
+
+- [`@ember/render-modifiers`](https://github.com/emberjs/ember-render-modifiers)
+- [Blog post about modifiers](https://blog.emberjs.com/2019/03/06/coming-soon-in-ember-octane-part-4.html)
diff --git a/lib/index.js b/lib/index.js
index dd348d6d..4d90dbe7 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -19,6 +19,7 @@ module.exports = {
     'no-capital-letters-in-routes': require('./rules/no-capital-letters-in-routes'),
     'no-classic-classes': require('./rules/no-classic-classes'),
     'no-classic-components': require('./rules/no-classic-components'),
+    'no-component-lifecycle-hooks': require('./rules/no-component-lifecycle-hooks'),
     'no-computed-properties-in-native-classes': require('./rules/no-computed-properties-in-native-classes'),
     'no-controllers': require('./rules/no-controllers'),
     'no-deeply-nested-dependent-keys-with-each': require('./rules/no-deeply-nested-dependent-keys-with-each'),
diff --git a/lib/octane-rules.js b/lib/octane-rules.js
index ac35e2fc..371af870 100644
--- a/lib/octane-rules.js
+++ b/lib/octane-rules.js
@@ -10,6 +10,7 @@ module.exports = {
   "ember/no-actions-hash": "error",
   "ember/no-classic-classes": "error",
   "ember/no-classic-components": "error",
+  "ember/no-component-lifecycle-hooks": "error",
   "ember/no-computed-properties-in-native-classes": "error",
   "ember/no-get-with-default": "error",
   "ember/no-get": "error",
diff --git a/lib/recommended-rules.js b/lib/recommended-rules.js
index 1b717845..d5456f8c 100644
--- a/lib/recommended-rules.js
+++ b/lib/recommended-rules.js
@@ -22,6 +22,7 @@ module.exports = {
   "ember/no-capital-letters-in-routes": "error",
   "ember/no-classic-classes": "off",
   "ember/no-classic-components": "off",
+  "ember/no-component-lifecycle-hooks": "off",
   "ember/no-computed-properties-in-native-classes": "off",
   "ember/no-controllers": "off",
   "ember/no-deeply-nested-dependent-keys-with-each": "error",
diff --git a/lib/rules/no-component-lifecycle-hooks.js b/lib/rules/no-component-lifecycle-hooks.js
new file mode 100644
index 00000000..6de3f66c
--- /dev/null
+++ b/lib/rules/no-component-lifecycle-hooks.js
@@ -0,0 +1,81 @@
+'use strict';
+
+const emberUtils = require('../utils/ember');
+
+const { isComponentLifecycleHook, isEmberComponent, isGlimmerComponent } = emberUtils;
+
+const ERROR_MESSAGE_NO_COMPONENT_LIFECYCLE_HOOKS =
+  'Do not use classic ember components lifecycle hooks. Prefer using "@ember/render-modifiers" or custom functional modifiers.';
+
+const report = (context, node) => {
+  context.report(node, ERROR_MESSAGE_NO_COMPONENT_LIFECYCLE_HOOKS);
+};
+
+module.exports = {
+  ERROR_MESSAGE_NO_COMPONENT_LIFECYCLE_HOOKS,
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'disallow usage of "classic" ember component lifecycle hooks. Render modifiers or custom functional modifiers should be used instead.',
+      category: 'Ember Octane',
+      recommended: false,
+      octane: true,
+      url:
+        'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-component-lifecycle-hooks.md',
+    },
+    fixable: null, // or "code" or "whitespace"
+    schema: [],
+  },
+
+  create(context) {
+    let isInsideEmberComponent = false;
+    let currentEmberComponent = null;
+
+    const isAClassicLifecycleHook = node => {
+      return isComponentLifecycleHook(node) && isInsideEmberComponent;
+    };
+
+    return {
+      ClassDeclaration(node) {
+        if (isEmberComponent(context, node) || isGlimmerComponent(context, node)) {
+          currentEmberComponent = node;
+          isInsideEmberComponent = true;
+        }
+      },
+
+      CallExpression(node) {
+        if (isEmberComponent(context, node)) {
+          currentEmberComponent = node;
+          isInsideEmberComponent = true;
+        }
+      },
+
+      'ClassDeclaration:exit'(node) {
+        if (currentEmberComponent === node) {
+          currentEmberComponent = null;
+          isInsideEmberComponent = false;
+        }
+      },
+
+      'CallExpression:exit'(node) {
+        if (currentEmberComponent === node) {
+          currentEmberComponent = null;
+          isInsideEmberComponent = false;
+        }
+      },
+
+      MethodDefinition(node) {
+        if (isAClassicLifecycleHook(node)) {
+          report(context, node);
+        }
+      },
+
+      Property(node) {
+        if (isAClassicLifecycleHook(node)) {
+          report(context, node.key);
+        }
+      },
+    };
+  },
+};
diff --git a/lib/utils/ember.js b/lib/utils/ember.js
index 2bdf3344..ed89a09b 100644
--- a/lib/utils/ember.js
+++ b/lib/utils/ember.js
@@ -13,6 +13,7 @@ module.exports = {
 
   isEmberCoreModule,
   isEmberComponent,
+  isGlimmerComponent,
   isEmberController,
   isEmberMixin,
   isEmberRoute,
@@ -63,6 +64,7 @@ module.exports = {
 // Private
 const CORE_MODULE_IMPORT_PATHS = {
   Component: '@ember/component',
+  GlimmerComponent: '@glimmer/component',
   Controller: '@ember/controller',
   Mixin: '@ember/object/mixin',
   Route: '@ember/routing/route',
@@ -199,6 +201,10 @@ function isEmberComponent(context, node) {
   return isEmberCoreModule(context, node, 'Component');
 }
 
+function isGlimmerComponent(context, node) {
+  return isEmberCoreModule(context, node, 'GlimmerComponent');
+}
+
 function isEmberController(context, node) {
   return isEmberCoreModule(context, node, 'Controller');
 }


Test patch:
diff --git a/tests/lib/rules/no-component-lifecycle-hooks.js b/tests/lib/rules/no-component-lifecycle-hooks.js
new file mode 100644
index 00000000..259e9c7b
--- /dev/null
+++ b/tests/lib/rules/no-component-lifecycle-hooks.js
@@ -0,0 +1,168 @@
+'use strict';
+
+const rule = require('../../../lib/rules/no-component-lifecycle-hooks');
+const RuleTester = require('eslint').RuleTester;
+
+const { ERROR_MESSAGE_NO_COMPONENT_LIFECYCLE_HOOKS: ERROR_MESSAGE } = rule;
+
+const ruleTester = new RuleTester({
+  parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+});
+
+ruleTester.run('no-component-lifecycle-hooks', rule, {
+  valid: [
+    `
+      import Component from '@glimmer/component';
+      export default class MyComponent extends Component {
+        willDestroy() {}
+      }
+    `,
+    `
+      import Component from '@glimmer/component';
+      export default class MyClass {
+        didUpdate() {}
+      }
+    `,
+    `
+      export default {
+        didUpdate() {},
+      }
+    `,
+    `
+      import Component from '@ember/component';
+      export default Component.extend({
+        willDestroy() {},
+      });
+    `,
+    `
+      export default EmberObject.extend({
+        didUpdate() {},
+      });
+    `,
+    `
+      import Component from '@ember/component';
+      export const Component1 = Component.extend({
+        willDestroy() {},
+      });
+      export const Component2 = Component.extend({
+        willDestroy() {},
+      });
+    `,
+    `
+      import Component from '@ember/component';
+      export default class MyComponent extends Component {}
+
+      const someRandomClassOrObject = { didDestroyElement() { } };
+    `,
+    `
+      import Component from '@ember/component';
+      export default Component.extend({});
+
+      const someRandomClassOrObject = { didDestroyElement() { } };
+    `,
+  ],
+
+  invalid: [
+    {
+      code: `
+        import Component from '@glimmer/component';
+        export default class MyComponent extends Component {
+          didDestroyElement() {}
+        }
+      `,
+      output: null,
+      errors: [
+        {
+          message: ERROR_MESSAGE,
+          type: 'MethodDefinition',
+        },
+      ],
+    },
+    {
+      code: `
+        import Component from '@ember/component';
+        export default class MyComponent extends Component {
+          didDestroyElement() {}
+        }
+      `,
+      output: null,
+      errors: [
+        {
+          message: ERROR_MESSAGE,
+          type: 'MethodDefinition',
+        },
+      ],
+    },
+    {
+      code: `
+        import Component from '@ember/component';
+        export default Component.extend({
+          didDestroyElement() {}
+        })
+      `,
+      output: null,
+      errors: [
+        {
+          message: ERROR_MESSAGE,
+          type: 'Identifier',
+        },
+      ],
+    },
+    {
+      code: `
+        import Component from '@ember/component';
+
+        export const Component2 = Component.extend({
+          didDestroyElement() {},
+        });
+
+        export const Component1 = Component.extend({
+          willDestroy() {},
+        });
+      `,
+      output: null,
+      errors: [
+        {
+          message: ERROR_MESSAGE,
+          type: 'Identifier',
+        },
+      ],
+    },
+    {
+      code: `
+        import Component from "@ember/component";
+
+        export const Component1 = Component.extend({
+          test: computed('', function () {}),
+          didDestroyElement() {},
+        });
+      `,
+      output: null,
+      errors: [
+        {
+          message: ERROR_MESSAGE,
+          type: 'Identifier',
+        },
+      ],
+    },
+    {
+      code: `
+        import Component from "@glimmer/component";
+
+        export default class MyComponent extends Component {
+          myMethod() {
+            class FooBarClass {}
+          }
+          didDestroyElement() {}
+        }
+      `,
+      output: null,
+      errors: [
+        {
+          message: ERROR_MESSAGE,
+          type: 'MethodDefinition',
+        },
+      ],
+    },
+  ],
+});
diff --git a/tests/lib/utils/ember-test.js b/tests/lib/utils/ember-test.js
index 0b33a9bb..1c52024d 100644
--- a/tests/lib/utils/ember-test.js
+++ b/tests/lib/utils/ember-test.js
@@ -221,6 +221,48 @@ describe('isEmberComponent', () => {
   });
 });
 
+describe('isGlimerComponent', () => {
+  describe("should check if it's a Glimmer Component", () => {
+    it('should detect Component when using native classes', () => {
+      const context = new FauxContext(`
+        import Component from '@glimmer/component';
+        class MyComponent extends Component {}
+      `);
+      const node = context.ast.body[1];
+      expect(emberUtils.isGlimmerComponent(context, node)).toBeTruthy();
+    });
+
+    it('shouldnt detect Component when using native classes if the import path is incorrect', () => {
+      const context = new FauxContext(`
+        import Component from '@something-else/component';
+        class MyComponent extends Component {}
+      `);
+      const node = context.ast.body[1];
+      expect(emberUtils.isGlimmerComponent(context, node)).toBeFalsy();
+    });
+
+    it('shouldnt confuse Glimmer Components with Ember Components', () => {
+      const context = new FauxContext(`
+        import Component from '@ember/component';
+        class MyComponent extends Component {}
+      `);
+      const node = context.ast.body[1];
+      expect(emberUtils.isGlimmerComponent(context, node)).toBeFalsy();
+    });
+  });
+
+  describe("should check if it's a Glimmer Component even if it uses a custom name", () => {
+    it('should detect Component when using native classes if the import path is correct', () => {
+      const context = new FauxContext(`
+        import CustomComponent from '@glimmer/component';
+        class MyComponent extends CustomComponent {}
+      `);
+      const node = context.ast.body[1];
+      expect(emberUtils.isGlimmerComponent(context, node)).toBeTruthy();
+    });
+  });
+});
+
 describe('isEmberController', () => {
   describe("should check if it's an Ember Controller", () => {
     it('should detect Controller when using Ember.Controller', () => {