diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f0bfec7d..973abf298b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2754,3 +2754,4 @@ If you're still not using React 15 you can keep the old behavior by setting the [`static-property-placement`]: docs/rules/static-property-placement.md [`jsx-curly-newline`]: docs/rules/jsx-curly-newline.md [`jsx-no-useless-fragment`]: docs/rules/jsx-no-useless-fragment.md +[`no-unused-class-component-methods`]: docs/rules/no-unused-class-component-methods.md diff --git a/docs/rules/no-unused-class-component-methods.md b/docs/rules/no-unused-class-component-methods.md new file mode 100644 index 0000000000..b8edbffaff --- /dev/null +++ b/docs/rules/no-unused-class-component-methods.md @@ -0,0 +1,31 @@ +# Prevent declaring unused methods of component class (react/no-unused-class-component-methods) + +Warns you if you have defined a method but it is never being used anywhere. + +## Rule Details + +The following patterns are considered warnings: + +```jsx +class Foo extends React.Component { + handleClick() {} + render() { + return null; + } +} +``` + +The following patterns are **not** considered warnings: + +```jsx +class Foo extends React.Component { + action() {} + componentDidMount() { + this.action(); + } + render() { + return null; + } +} +}); +``` diff --git a/index.js b/index.js index c9511bb1e3..c8982adca1 100644 --- a/index.js +++ b/index.js @@ -71,6 +71,7 @@ const allRules = { 'no-unescaped-entities': require('./lib/rules/no-unescaped-entities'), 'no-unknown-property': require('./lib/rules/no-unknown-property'), 'no-unsafe': require('./lib/rules/no-unsafe'), + 'no-unused-class-component-methods': require('./lib/rules/no-unused-class-component-methods'), 'no-unused-prop-types': require('./lib/rules/no-unused-prop-types'), 'no-unused-state': require('./lib/rules/no-unused-state'), 'no-will-update-set-state': require('./lib/rules/no-will-update-set-state'), diff --git a/lib/rules/no-unused-class-component-methods.js b/lib/rules/no-unused-class-component-methods.js new file mode 100644 index 0000000000..ab75920f60 --- /dev/null +++ b/lib/rules/no-unused-class-component-methods.js @@ -0,0 +1,162 @@ +/** + * @fileoverview Prevent declaring unused methods of component class + * @author Paweł Nowak + */ + +'use strict'; + +const Components = require('../util/Components'); +const astUtil = require('../util/ast'); +const docsUrl = require('../util/docsUrl'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +const internalMethods = [ + 'constructor', + 'componentDidCatch', + 'componentDidMount', + 'componentDidUpdate', + 'componentWillMount', + 'componentWillReceiveProps', + 'componentWillUnmount', + 'componentWillUpdate', + 'getSnapshotBeforeUpdate', + 'render', + 'shouldComponentUpdate', + 'UNSAFE_componentWillMount', + 'UNSAFE_componentWillReceiveProps', + 'UNSAFE_componentWillUpdate' +]; + +module.exports = { + meta: { + docs: { + description: 'Prevent declaring unused methods of component class', + category: 'Best Practices', + recommended: false, + url: docsUrl('no-unused-class-component-methods') + }, + schema: [ + { + type: 'object', + additionalProperties: false + } + ] + }, + + create: Components.detect((context, components, utils) => { + const isNotComponent = node => ( + !utils.isES5Component(node) && + !utils.isES6Component(node) && + !utils.isCreateElement(node) + ); + const filterAllMethods = node => { + const isMethod = node.type === 'MethodDefinition'; + const isArrowFunction = ( + node.type === 'ClassProperty' && + node.value.type === 'ArrowFunctionExpression' + ); + const isFunctionExpression = ( + node.type === 'ClassProperty' && + node.value.type === 'FunctionExpression' + ); + + return isMethod || isArrowFunction || isFunctionExpression; + }; + const checkMethods = node => { + const properties = astUtil.getComponentProperties(node); + let methods = properties + .filter(property => ( + filterAllMethods(property) && + internalMethods.indexOf(astUtil.getPropertyName(property)) === -1 + )); + const getThisExpressions = subnode => { + if (!methods.length) { + return; + } + + switch (subnode.type) { + case 'ClassProperty': + case 'JSXAttribute': + case 'MethodDefinition': + getThisExpressions(subnode.value); + break; + case 'ArrowFunctionExpression': + case 'FunctionExpression': + getThisExpressions(subnode.body); + break; + case 'BlockStatement': + subnode.body.forEach(getThisExpressions); + break; + case 'ReturnStatement': + getThisExpressions(subnode.argument); + break; + case 'JSXElement': + getThisExpressions(subnode.openingElement); + subnode.children.forEach(getThisExpressions); + break; + case 'JSXOpeningElement': + subnode.attributes.forEach(getThisExpressions); + break; + case 'JSXExpressionContainer': + case 'ExpressionStatement': + getThisExpressions(subnode.expression); + break; + case 'CallExpression': + getThisExpressions(subnode.callee); + break; + case 'VariableDeclaration': + subnode.declarations.forEach(getThisExpressions); + break; + case 'VariableDeclarator': + getThisExpressions(subnode.init); + break; + case 'MemberExpression': + if (subnode.object.type !== 'ThisExpression') { + return; + } + + methods = methods.filter(method => + subnode.property.name !== astUtil.getPropertyName(method) + ); + break; + default: + break; + } + }; + + properties.forEach(getThisExpressions); + + if (!methods.length) { + return; + } + + methods.forEach(method => { + context.report({ + node: method, + message: 'Unused method "{{method}}" of class "{{class}}"', + data: { + class: node.id.name, + method: astUtil.getPropertyName(method) + } + }); + }); + }; + + return { + 'Program:exit': () => { + const list = components.list(); + + Object.values(list).forEach(({ node }) => { + if (isNotComponent(node)) { + return; + } + + checkMethods(node); + }); + } + }; + }) +}; diff --git a/tests/lib/rules/no-unused-class-component-methods.js b/tests/lib/rules/no-unused-class-component-methods.js new file mode 100644 index 0000000000..cb2c278b5a --- /dev/null +++ b/tests/lib/rules/no-unused-class-component-methods.js @@ -0,0 +1,388 @@ +/** + * @fileoverview Prevent declaring unused methods of component class + * @author Paweł Nowak + */ +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/no-unused-class-component-methods'); +const RuleTester = require('eslint').RuleTester; + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true + } +}; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({parserOptions}); +ruleTester.run('no-unused-class-component-methods', rule, { + valid: [ + { + code: ` + class Foo extends React.Component { + handleClick() {} + render() { + return ; + } + } + ` + }, + { + code: ` + class Foo extends React.Component { + action() {} + componentDidMount() { + this.action(); + } + render() { + return null; + } + } + ` + }, + { + code: ` + class Foo extends React.Component { + action() {} + componentDidMount() { + const action = this.action; + action(); + } + render() { + return null; + } + } + ` + }, + { + code: ` + class Foo extends React.Component { + getValue() {} + componentDidMount() { + const action = this.getValue(); + } + render() { + return null; + } + } + ` + }, + { + code: ` + class Foo extends React.Component { + handleClick = () => {} + render() { + return ; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + renderContent() {} + render() { + return
{this.renderContent()}
; + } + } + ` + }, + { + code: ` + class Foo extends React.Component { + renderContent() {} + render() { + return ( +
+
{this.renderContent()}
; +
+ ); + } + } + ` + }, + { + code: ` + class Foo extends React.Component { + property = {} + render() { + return
Example
; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + action = () => {} + anotherAction = () => { + this.action(); + } + render() { + return ; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + action = () => {} + anotherAction = () => this.action() + render() { + return ; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + getValue = () => {} + value = this.getValue() + render() { + return null; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + var Foo = React.createClass({ + action: function () {}, + render: function () { + return ; + } + }); + ` + }, + { + code: ` + class Foo { + action = () => {} + anotherAction = () => this.action() + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + action = async () => {} + render() { + return ; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + async action() { + console.log('error'); + } + render() { + return ; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + * action() { + console.log('error'); + } + render() { + return ; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + async * action() { + console.log('error'); + } + render() { + return ; + } + } + `, + parser: 'babel-eslint' + }, + { + code: ` + class Foo extends React.Component { + action = function() { + console.log('error'); + } + render() { + return ; + } + } + `, + parser: 'babel-eslint' + } + ], + + invalid: [ + { + code: ` + class Foo extends React.Component { + handleClick() {} + render() { + return null; + } + } + `, + errors: [{ + message: 'Unused method "handleClick" of class "Foo"', + line: 3, + column: 11 + }] + }, + { + code: ` + class Foo extends React.Component { + handleScroll() {} + handleClick() {} + render() { + return null; + } + } + `, + errors: [{ + message: 'Unused method "handleScroll" of class "Foo"', + line: 3, + column: 11 + }, { + message: 'Unused method "handleClick" of class "Foo"', + line: 4, + column: 11 + }] + }, + { + code: ` + class Foo extends React.Component { + handleClick = () => {} + render() { + return null; + } + } + `, + parser: 'babel-eslint', + errors: [{ + message: 'Unused method "handleClick" of class "Foo"', + line: 3, + column: 11 + }] + }, + { + code: ` + class Foo extends React.Component { + action = async () => {} + render() { + return null; + } + } + `, + parser: 'babel-eslint', + errors: [{ + message: 'Unused method "action" of class "Foo"', + line: 3, + column: 11 + }] + }, + { + code: ` + class Foo extends React.Component { + async action() { + console.log('error'); + } + render() { + return null; + } + } + `, + parser: 'babel-eslint', + errors: [{ + message: 'Unused method "action" of class "Foo"', + line: 3, + column: 11 + }] + }, + { + code: ` + class Foo extends React.Component { + * action() { + console.log('error'); + } + render() { + return null; + } + } + `, + parser: 'babel-eslint', + errors: [{ + message: 'Unused method "action" of class "Foo"', + line: 3, + column: 11 + }] + }, + { + code: ` + class Foo extends React.Component { + async * action() { + console.log('error'); + } + render() { + return null; + } + } + `, + parser: 'babel-eslint', + errors: [{ + message: 'Unused method "action" of class "Foo"', + line: 3, + column: 11 + }] + }, + { + code: ` + class Foo extends React.Component { + action = function() { + console.log('error'); + } + render() { + return null; + } + } + `, + parser: 'babel-eslint', + errors: [{ + message: 'Unused method "action" of class "Foo"', + line: 3, + column: 11 + }] + } + ] +});