diff --git a/packages/angular-eslint/src/configs/ts-all.ts b/packages/angular-eslint/src/configs/ts-all.ts
index 7c2659c69..f8afc489b 100644
--- a/packages/angular-eslint/src/configs/ts-all.ts
+++ b/packages/angular-eslint/src/configs/ts-all.ts
@@ -46,6 +46,7 @@ export default (
'@angular-eslint/prefer-signals': 'error',
'@angular-eslint/prefer-standalone': 'error',
'@angular-eslint/relative-url-prefix': 'error',
+ '@angular-eslint/require-lifecycle-on-prototype': 'error',
'@angular-eslint/require-localize-metadata': 'error',
'@angular-eslint/runtime-localize': 'error',
'@angular-eslint/sort-lifecycle-methods': 'error',
diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index bc2ffa126..67d6134bf 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -24,6 +24,7 @@ Please see https://github.com/angular-eslint/angular-eslint for full usage instr
| [`contextual-lifecycle`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/contextual-lifecycle.md) | Ensures that lifecycle methods are used in a correct context | :white_check_mark: | | |
| [`no-async-lifecycle-method`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-async-lifecycle-method.md) | Angular Lifecycle methods should not be async. Angular does not wait for async lifecycle but the code incorrectly suggests it does. | | | |
| [`no-attribute-decorator`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-attribute-decorator.md) | The @Attribute decorator is used to obtain a single value for an attribute. This is a much less common use-case than getting a stream of values (using @Input), so often the @Attribute decorator is mistakenly used when @Input was what was intended. This rule disallows usage of @Attribute decorator altogether in order to prevent these mistakes. | | | |
+| [`require-lifecycle-on-prototype`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md) | Ensures that lifecycle methods are defined on the object's prototype instead of on an instance. | | | |
| [`sort-lifecycle-methods`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/sort-lifecycle-methods.md) | Ensures that lifecycle methods are declared in order of execution | | | |
diff --git a/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md b/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md
new file mode 100644
index 000000000..e6d1d71a5
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md
@@ -0,0 +1,1533 @@
+
+
+
+
+# `@angular-eslint/require-lifecycle-on-prototype`
+
+Ensures that lifecycle methods are defined on the object's prototype instead of on an instance.
+
+- Type: problem
+
+
+
+## Rule Options
+
+The rule does not have any configuration options.
+
+
+
+## Usage Examples
+
+> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit.
+
+
+
+
+❌ - Toggle examples of incorrect code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngOnChanges = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngOnInit = func;
+ ~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngDoCheck = func;
+ ~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterContentInit = func;
+ ~~~~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterContentChecked = func;
+ ~~~~~~~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterViewInit = func;
+ ~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterViewChecked = func;
+ ~~~~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({})
+class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngOnChanges = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngOnInit = func;
+ ~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngDoCheck = func;
+ ~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterContentInit = func;
+ ~~~~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterContentChecked = func;
+ ~~~~~~~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterViewInit = func;
+ ~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterViewChecked = func;
+ ~~~~~~~~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Directive({})
+class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Injectable({})
+class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Pipe({})
+class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+class Test {
+ constructor() {
+ this.ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+class Test {
+ constructor() {
+ this['ngOnDestroy'] = func;
+ ~~~~~~~~~~~~~
+ }
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+class Test {
+ run() {
+ this.ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+function hook(component) {
+ component.ngOnDestroy = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+function hook(component) {
+ (component as any).ngOnDestroy = func;
+ ~~~~~~~~~~~
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+class Test {
+ ['ngOnDestroy'] = func;
+ ~~~~~~~~~~~~~
+}
+```
+
+
+
+
+
+---
+
+
+
+
+✅ - Toggle examples of correct code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterContentChecked() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterContentInit() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterViewChecked() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngAfterViewInit() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngOnChanges() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngOnDestroy() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngOnInit() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ ngDoCheck() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterContentChecked() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterContentInit() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterViewChecked() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngAfterViewInit() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngOnChanges() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngOnDestroy() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngOnInit() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Directive({})
+class Test {
+ ngDoCheck() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Injectable({})
+class Test {
+ ngOnDestroy() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Pipe({})
+class Test {
+ ngOnDestroy() {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {}
+function hook(type) {
+ type.prototype.ngOnDestroy = () => {};
+}
+hook(Test);
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {}
+function hook(type) {
+ (type.prototype as any).ngOnDestroy = () => {};
+}
+hook(Test);
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {}
+function hook(type) {
+ type['prototype'].ngOnDestroy = () => {};
+}
+hook(Test);
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {}
+function hook(type) {
+ (type.prototype as unknown as { ngOnDestroy: () => void}).ngOnDestroy = () => {};
+}
+hook(Test);
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ onDestroy = () => {}
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ constructor() {
+ this.onDestroy = () => {}
+ }
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ constructor() {
+ this['onDestroy'] = () => {}
+ }
+}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/require-lifecycle-on-prototype": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({})
+class Test {
+ constructor() {
+ let ngOnDestroy;
+ ngOnDestroy = () => {};
+ }
+}
+```
+
+
+
+
diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json
index f1b94a661..4cd036f49 100644
--- a/packages/eslint-plugin/src/configs/all.json
+++ b/packages/eslint-plugin/src/configs/all.json
@@ -32,6 +32,7 @@
"@angular-eslint/prefer-signals": "error",
"@angular-eslint/prefer-standalone": "error",
"@angular-eslint/relative-url-prefix": "error",
+ "@angular-eslint/require-lifecycle-on-prototype": "error",
"@angular-eslint/require-localize-metadata": "error",
"@angular-eslint/runtime-localize": "error",
"@angular-eslint/sort-lifecycle-methods": "error",
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index 1577000ec..0c0fbe8e7 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -88,6 +88,9 @@ import preferStandalone, {
import relativeUrlPrefix, {
RULE_NAME as relativeUrlPrefixRuleName,
} from './rules/relative-url-prefix';
+import requireLifecycleOnPrototype, {
+ RULE_NAME as requireLifecycleOnPrototypeRuleName,
+} from './rules/require-lifecycle-on-prototype';
import requireLocalizeMetadata, {
RULE_NAME as requireLocalizeMetadataRuleName,
} from './rules/require-localize-metadata';
@@ -153,6 +156,7 @@ export = {
[preferStandaloneRuleName]: preferStandalone,
[preferOutputReadonlyRuleName]: preferOutputReadonly,
[relativeUrlPrefixRuleName]: relativeUrlPrefix,
+ [requireLifecycleOnPrototypeRuleName]: requireLifecycleOnPrototype,
[requireLocalizeMetadataRuleName]: requireLocalizeMetadata,
[runtimeLocalizeRuleName]: runtimeLocalize,
[sortLifecycleMethodsRuleName]: sortLifecycleMethods,
diff --git a/packages/eslint-plugin/src/rules/require-lifecycle-on-prototype.ts b/packages/eslint-plugin/src/rules/require-lifecycle-on-prototype.ts
new file mode 100644
index 000000000..f0a2d29b9
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/require-lifecycle-on-prototype.ts
@@ -0,0 +1,82 @@
+import { AST_NODE_TYPES } from '@typescript-eslint/utils';
+import type { TSESTree } from '@typescript-eslint/utils';
+import { createESLintRule } from '../utils/create-eslint-rule';
+import { ASTUtils, toPattern } from '@angular-eslint/utils';
+
+export type Options = [];
+
+const ISSUE_LINK = 'https://github.com/angular/angular/issues/38241';
+
+export type MessageIds = 'defineOnPrototype';
+export const RULE_NAME = 'require-lifecycle-on-prototype';
+
+const angularLifecycleMethodsPattern = toPattern([
+ ...ASTUtils.ANGULAR_LIFECYCLE_METHODS,
+]);
+const propertyDefinitionSelector = `PropertyDefinition > ${createAngularLifecycleMethodsPattern('key')}`;
+const assignmentSelector = `AssignmentExpression[operator="="] > MemberExpression.left > ${createAngularLifecycleMethodsPattern('property')}`;
+
+function createAngularLifecycleMethodsPattern(parentProperty: string): string {
+ return `:matches(Literal.${parentProperty}[value=${angularLifecycleMethodsPattern}], Identifier.${parentProperty}[name=${angularLifecycleMethodsPattern}])`;
+}
+
+export default createESLintRule({
+ name: RULE_NAME,
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ "Ensures that lifecycle methods are defined on the object's prototype instead of on an instance.",
+ },
+ schema: [],
+ messages: {
+ defineOnPrototype: `The {{ method }} lifecycle method should be defined on the object's prototype. See more at ${ISSUE_LINK}`,
+ },
+ },
+ defaultOptions: [],
+ create(context) {
+ return {
+ [propertyDefinitionSelector](
+ node: TSESTree.Literal | TSESTree.Identifier,
+ ) {
+ context.report({
+ node,
+ messageId: 'defineOnPrototype',
+ data: {
+ method:
+ node.type === AST_NODE_TYPES.Literal ? node.value : node.name,
+ },
+ });
+ },
+ [assignmentSelector](node: TSESTree.Literal | TSESTree.Identifier) {
+ // Assigning to the prototype is OK.
+ if (!isPrototype((node.parent as TSESTree.MemberExpression).object)) {
+ context.report({
+ node,
+ messageId: 'defineOnPrototype',
+ data: {
+ method:
+ node.type === AST_NODE_TYPES.Literal ? node.value : node.name,
+ },
+ });
+ }
+ },
+ };
+ },
+});
+
+function isPrototype(node: TSESTree.Node): boolean {
+ while (node.type === AST_NODE_TYPES.TSAsExpression) {
+ node = node.expression;
+ }
+ if (node.type === AST_NODE_TYPES.MemberExpression) {
+ switch (node.property.type) {
+ case AST_NODE_TYPES.Identifier:
+ return node.property.name === 'prototype';
+
+ case AST_NODE_TYPES.Literal:
+ return node.property.value === 'prototype';
+ }
+ }
+ return false;
+}
diff --git a/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/cases.ts b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/cases.ts
new file mode 100644
index 000000000..3c7d29bbf
--- /dev/null
+++ b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/cases.ts
@@ -0,0 +1,476 @@
+import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/test-utils';
+import type {
+ InvalidTestCase,
+ ValidTestCase,
+} from '@typescript-eslint/rule-tester';
+import type {
+ MessageIds,
+ Options,
+} from '../../../src/rules/require-lifecycle-on-prototype';
+
+const messageId: MessageIds = 'defineOnPrototype';
+
+export const valid: readonly (string | ValidTestCase)[] = [
+ `
+ @Component({})
+ class Test {
+ ngAfterContentChecked() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ ngAfterContentInit() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ ngAfterViewChecked() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ ngAfterViewInit() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ ngOnChanges() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ ngOnDestroy() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ ngOnInit() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ ngDoCheck() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngAfterContentChecked() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngAfterContentInit() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngAfterViewChecked() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngAfterViewInit() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngOnChanges() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngOnDestroy() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngOnInit() {}
+ }
+ `,
+ `
+ @Directive({})
+ class Test {
+ ngDoCheck() {}
+ }
+ `,
+ `
+ @Injectable({})
+ class Test {
+ ngOnDestroy() {}
+ }
+ `,
+ `
+ @Pipe({})
+ class Test {
+ ngOnDestroy() {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {}
+ function hook(type) {
+ type.prototype.ngOnDestroy = () => {};
+ }
+ hook(Test);
+ `,
+ `
+ @Component({})
+ class Test {}
+ function hook(type) {
+ (type.prototype as any).ngOnDestroy = () => {};
+ }
+ hook(Test);
+ `,
+ `
+ @Component({})
+ class Test {}
+ function hook(type) {
+ type['prototype'].ngOnDestroy = () => {};
+ }
+ hook(Test);
+ `,
+ `
+ @Component({})
+ class Test {}
+ function hook(type) {
+ (type.prototype as unknown as { ngOnDestroy: () => void}).ngOnDestroy = () => {};
+ }
+ hook(Test);
+ `,
+ `
+ @Component({})
+ class Test {
+ onDestroy = () => {}
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ constructor() {
+ this.onDestroy = () => {}
+ }
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ constructor() {
+ this['onDestroy'] = () => {}
+ }
+ }
+ `,
+ `
+ @Component({})
+ class Test {
+ constructor() {
+ let ngOnDestroy;
+ ngOnDestroy = () => {};
+ }
+ }
+ `,
+];
+
+export const invalid: readonly InvalidTestCase[] = [
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngOnChanges property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngOnChanges = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnChanges' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngOnInit property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngOnInit = func;
+ ~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnInit' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngDoCheck property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngDoCheck = func;
+ ~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngDoCheck' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngAfterContentInit property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngAfterContentInit = func;
+ ~~~~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterContentInit' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngAfterContentChecked property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngAfterContentChecked = func;
+ ~~~~~~~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterContentChecked' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngAfterViewInit property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngAfterViewInit = func;
+ ~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterViewInit' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngAfterViewChecked property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngAfterViewChecked = func;
+ ~~~~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterViewChecked' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when component has ngOnDestroy property initialized to value.`,
+ annotatedSource: `
+ @Component({})
+ class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngOnChanges property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngOnChanges = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnChanges' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngOnInit property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngOnInit = func;
+ ~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnInit' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngDoCheck property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngDoCheck = func;
+ ~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngDoCheck' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngAfterContentInit property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngAfterContentInit = func;
+ ~~~~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterContentInit' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngAfterContentChecked property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngAfterContentChecked = func;
+ ~~~~~~~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterContentChecked' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngAfterViewInit property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngAfterViewInit = func;
+ ~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterViewInit' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngAfterViewChecked property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngAfterViewChecked = func;
+ ~~~~~~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngAfterViewChecked' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when directive has ngOnDestroy property initialized to value.`,
+ annotatedSource: `
+ @Directive({})
+ class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when injectable has ngOnDestroy property initialized to value.`,
+ annotatedSource: `
+ @Injectable({})
+ class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when pipe has ngOnDestroy property initialized to value.`,
+ annotatedSource: `
+ @Pipe({})
+ class Test {
+ ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when lifecycle property is initialized in constructor.`,
+ annotatedSource: `
+ class Test {
+ constructor() {
+ this.ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when lifecycle property is initialized in constructor using literal notation.`,
+ annotatedSource: `
+ class Test {
+ constructor() {
+ this['ngOnDestroy'] = func;
+ ~~~~~~~~~~~~~
+ }
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when lifecycle property is initialized in method.`,
+ annotatedSource: `
+ class Test {
+ run() {
+ this.ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when lifecycle property is set outside of class.`,
+ annotatedSource: `
+ function hook(component) {
+ component.ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when cast is used to set lifecycle property.`,
+ annotatedSource: `
+ function hook(component) {
+ (component as any).ngOnDestroy = func;
+ ~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when literal notation is used for property name.`,
+ annotatedSource: `
+ class Test {
+ ['ngOnDestroy'] = func;
+ ~~~~~~~~~~~~~
+ }
+ `,
+ messageId,
+ data: { method: 'ngOnDestroy' },
+ }),
+];
diff --git a/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/spec.ts b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/spec.ts
new file mode 100644
index 000000000..47f8cef9e
--- /dev/null
+++ b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/spec.ts
@@ -0,0 +1,12 @@
+import { RuleTester } from '@angular-eslint/test-utils';
+import rule, {
+ RULE_NAME,
+} from '../../../src/rules/require-lifecycle-on-prototype';
+import { invalid, valid } from './cases';
+
+const ruleTester = new RuleTester();
+
+ruleTester.run(RULE_NAME, rule, {
+ valid,
+ invalid,
+});