diff --git a/CHANGELOG.md b/CHANGELOG.md index 703ac049f5..b9cdd6b7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ## Unreleased +### Added +* [`no-unused-class-component-methods`]: Handle unused class component methods ([#2166][] @jakeleventhal @pawelnvk) + +[#2166]: https://github.com/yannickcr/eslint-plugin-react/pull/2166 + ## [7.26.1] - 2021.09.29 ### Fixed @@ -3482,6 +3487,7 @@ If you're still not using React 15 you can keep the old behavior by setting the [`no-unknown-property`]: docs/rules/no-unknown-property.md [`no-unsafe`]: docs/rules/no-unsafe.md [`no-unstable-nested-components`]: docs/rules/no-unstable-nested-components.md +[`no-unused-class-component-methods`]: docs/rules/no-unused-class-component-methods.md [`no-unused-prop-types`]: docs/rules/no-unused-prop-types.md [`no-unused-state`]: docs/rules/no-unused-state.md [`no-will-update-set-state`]: docs/rules/no-will-update-set-state.md diff --git a/README.md b/README.md index 6c5af3db27..09b4d59131 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ Enable the rules that you would like to use. | ✔ | 🔧 | [react/no-unknown-property](docs/rules/no-unknown-property.md) | Prevent usage of unknown DOM property | | | | [react/no-unsafe](docs/rules/no-unsafe.md) | Prevent usage of unsafe lifecycle methods | | | | [react/no-unstable-nested-components](docs/rules/no-unstable-nested-components.md) | Prevent creating unstable components inside components | +| | | [react/no-unused-class-component-methods](docs/rules/no-unused-class-component-methods.md) | Prevent declaring unused methods of component class | | | | [react/no-unused-prop-types](docs/rules/no-unused-prop-types.md) | Prevent definitions of unused prop types | | | | [react/no-unused-state](docs/rules/no-unused-state.md) | Prevent definition of unused state fields | | | | [react/no-will-update-set-state](docs/rules/no-will-update-set-state.md) | Prevent usage of setState in componentWillUpdate | 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..62873e6a53 --- /dev/null +++ b/docs/rules/no-unused-class-component-methods.md @@ -0,0 +1,34 @@ +# Prevent declaring unused methods of component class (react/no-unused-class-component-methods) + +Warns you if you have defined a method or property 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 { + static getDerivedStateFromError(error) { + return { hasError: true }; + } + action() {} + componentDidMount() { + this.action(); + } + render() { + return null; + } +} +}); +``` diff --git a/index.js b/index.js index 198af7b37f..700780b3a9 100644 --- a/index.js +++ b/index.js @@ -78,6 +78,7 @@ const allRules = { 'no-unknown-property': require('./lib/rules/no-unknown-property'), 'no-unsafe': require('./lib/rules/no-unsafe'), 'no-unstable-nested-components': require('./lib/rules/no-unstable-nested-components'), + '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..4e27cec6e9 --- /dev/null +++ b/lib/rules/no-unused-class-component-methods.js @@ -0,0 +1,244 @@ +/** + * @fileoverview Prevent declaring unused methods and properties of component class + * @author Paweł Nowak, Berton Zhu + */ + +'use strict'; + +const Components = require('../util/Components'); +const docsUrl = require('../util/docsUrl'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +const LIFECYCLE_METHODS = new Set([ + 'constructor', + 'componentDidCatch', + 'componentDidMount', + 'componentDidUpdate', + 'componentWillMount', + 'componentWillReceiveProps', + 'componentWillUnmount', + 'componentWillUpdate', + 'getSnapshotBeforeUpdate', + 'render', + 'shouldComponentUpdate', + 'UNSAFE_componentWillMount', + 'UNSAFE_componentWillReceiveProps', + 'UNSAFE_componentWillUpdate' +]); + +const ES6_LIFECYCLE = new Set([ + 'state' +]); + +const ES5_LIFECYCLE = new Set([ + 'getInitialState', + 'getDefaultProps', + 'mixins' +]); + +function isKeyLiteralLike(node, property) { + return property.type === 'Literal' + || (property.type === 'TemplateLiteral' && property.expressions.length === 0) + || (node.computed === false && property.type === 'Identifier'); +} + +// Descend through all wrapping TypeCastExpressions and return the expression +// that was cast. +function uncast(node) { + while (node.type === 'TypeCastExpression') { + node = node.expression; + } + return node; +} + +// Return the name of an identifier or the string value of a literal. Useful +// anywhere that a literal may be used as a key (e.g., member expressions, +// method definitions, ObjectExpression property keys). +function getName(node) { + node = uncast(node); + const type = node.type; + + if (type === 'Identifier') { + return node.name; + } + if (type === 'Literal') { + return String(node.value); + } + if (type === 'TemplateLiteral' && node.expressions.length === 0) { + return node.quasis[0].value.raw; + } + return null; +} + +function isThisExpression(node) { + return uncast(node).type === 'ThisExpression'; +} + +function getInitialClassInfo(node, isClass) { + return { + classNode: node, + isClass, + // Set of nodes where properties were defined. + properties: new Set(), + + // Set of names of properties that we've seen used. + usedProperties: new Set(), + + inStatic: false + }; +} + +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) => { + let classInfo = null; + + // Takes an ObjectExpression node and adds all named Property nodes to the + // current set of properties. + function addProperty(node) { + classInfo.properties.add(node); + } + + // Adds the name of the given node as a used property if the node is an + // Identifier or a Literal. Other node types are ignored. + function addUsedProperty(node) { + const name = getName(node); + if (name) { + classInfo.usedProperties.add(name); + } + } + + function reportUnusedProperties() { + // Report all unused properties. + for (const node of classInfo.properties) { // eslint-disable-line no-restricted-syntax + const name = getName(node); + if ( + !classInfo.usedProperties.has(name) + && !LIFECYCLE_METHODS.has(name) + && (classInfo.isClass ? !ES6_LIFECYCLE.has(name) : !ES5_LIFECYCLE.has(name)) + ) { + const className = (classInfo.classNode.id && classInfo.classNode.id.name) || ''; + + context.report({ + node, + message: `Unused method or property "${name}"${className ? ` of class "${className}"` : ''}` + }); + } + } + } + + function exitMethod() { + if (!classInfo || !classInfo.inStatic) { + return; + } + + classInfo.inStatic = false; + } + + return { + ClassDeclaration(node) { + if (utils.isES6Component(node)) { + classInfo = getInitialClassInfo(node, true); + } + }, + + ObjectExpression(node) { + if (utils.isES5Component(node)) { + classInfo = getInitialClassInfo(node, false); + } + }, + + 'ClassDeclaration:exit'() { + if (!classInfo) { + return; + } + reportUnusedProperties(); + classInfo = null; + }, + + 'ObjectExpression:exit'(node) { + if (!classInfo || classInfo.classNode !== node) { + return; + } + reportUnusedProperties(); + classInfo = null; + }, + + Property(node) { + if (!classInfo || classInfo.classNode !== node.parent) { + return; + } + + if (isKeyLiteralLike(node, node.key)) { + addProperty(node.key); + } + }, + + 'ClassProperty, MethodDefinition'(node) { + if (!classInfo) { + return; + } + + if (node.static) { + classInfo.inStatic = true; + return; + } + + if (isKeyLiteralLike(node, node.key)) { + addProperty(node.key); + } + }, + + 'ClassProperty:exit': exitMethod, + 'MethodDefinition:exit': exitMethod, + + MemberExpression(node) { + if (!classInfo || classInfo.inStatic) { + return; + } + + if (isThisExpression(node.object) && isKeyLiteralLike(node, node.property)) { + if (node.parent.type === 'AssignmentExpression' && node.parent.left === node) { + // detect `this.property = xxx` + addProperty(node.property); + } else { + // detect `this.property()`, `x = this.property`, etc. + addUsedProperty(node.property); + } + } + }, + + VariableDeclarator(node) { + if (!classInfo || classInfo.inStatic) { + return; + } + + // detect `{ foo, bar: baz } = this` + if (node.init && isThisExpression(node.init) && node.id.type === 'ObjectPattern') { + node.id.properties.forEach((prop) => { + if (prop.type === 'Property' && isKeyLiteralLike(prop, prop.key)) { + addUsedProperty(prop.key); + } + }); + } + } + }; + }) +}; 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..ad46ad3765 --- /dev/null +++ b/tests/lib/rules/no-unused-class-component-methods.js @@ -0,0 +1,804 @@ +/** + * @fileoverview Prevent declaring unused methods and properties of component class + * @author Paweł Nowak, Berton Zhu + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/no-unused-class-component-methods'); +const parsers = require('../../helpers/parsers'); + +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 SmockTestForTypeOfNullError extends React.Component { + handleClick() {} + foo; + render() { + let a; + return ; + } + } + `, + parser: parsers.BABEL_ESLINT + }, + { + code: ` + class Foo extends React.Component { + handleClick() {} + render() { + return ; + } + } + ` + }, + { + code: ` + var Foo = createReactClass({ + handleClick() {}, + render() { + return ; + }, + }) + ` + }, + { + code: ` + class Foo extends React.Component { + action() {} + componentDidMount() { + this.action(); + } + render() { + return null; + } + } + ` + }, + { + code: ` + var Foo = createReactClass({ + 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: parsers.BABEL_ESLINT + }, + { + code: ` + class Foo extends React.Component { + renderContent() {} + render() { + return