github-desktop/eslint-rules/react-proper-lifecycle-methods.js
2021-07-06 15:37:58 -03:00

367 lines
10 KiB
JavaScript

// @ts-check
/**
* react-proper-lifecycle-methods
*
* This custom eslint rule is attempts to prevent erroneous usage of the React
* lifecycle methods by ensuring proper method naming, and parameter order,
* types and names.
*
*/
/**
* @typedef {import('@typescript-eslint/typescript-estree').TSESTree.ClassDeclaration} ClassDeclaration
* @typedef {import('@typescript-eslint/typescript-estree').TSESTree.Node} Node
* @typedef {import('@typescript-eslint/typescript-estree').TSESTree.Parameter} Parameter
* @typedef {import("@typescript-eslint/typescript-estree").TSESTree.MethodDefinition} MethodDefinition
* @typedef {import('@typescript-eslint/experimental-utils').TSESLint.RuleModule} RuleModule
*/
/**
* Extract the props type from the class declaration
*
* @param {ClassDeclaration} node
*
* @returns {string|null} a `string` if the props type can be resolved, `null` otherwise
*/
function getPropsType(node) {
if (!node.superTypeParameters) {
return null
}
if (node.superTypeParameters.params.length <= 0) {
return null
}
const propsParam = node.superTypeParameters.params[0]
if (
propsParam.type === 'TSTypeReference' &&
propsParam.typeName.type === 'Identifier'
) {
return propsParam.typeName.name
}
if (propsParam.type === 'TSTypeLiteral' && propsParam.members.length === 0) {
// TODO:
// if types are inlined, this needs to do that traversal so we can perform equivalence
// skipping this for now as I'm not aware of usages of this in desktop/desktop
return '{}'
}
return null
}
/**
* Extract the state type from the class declaration
*
* @param {ClassDeclaration} node
* @param {(node: Node) => string} getText
*
* @returns {string|null} a `string` if the props type can be resolved, `null` otherwise
*/
function getStateType(node, getText) {
if (node.superTypeParameters.params.length <= 1) {
return null
}
const propsParam = node.superTypeParameters.params[1]
if (
propsParam.type === 'TSTypeReference' &&
propsParam.typeName.type === 'Identifier'
) {
return propsParam.typeName.name
}
if (propsParam.type === 'TSTypeLiteral') {
return getText(propsParam)
}
return null
}
/**
* Check if the encountered class subclasses React.Component or React.PureComponent
*
* @param {ClassDeclaration} node
*
* @returns {boolean} `true` if the superclass matches React.Component or React.PureComponent, or `false` in all other cases
*/
function extendsReactComponent(node) {
if (!node.superClass) {
return false
}
if (node.superClass.type !== 'MemberExpression') {
return false
}
if (node.superClass.object.type !== 'Identifier') {
return false
}
const name = node.superClass.object.name
if (name !== 'React') {
return false
}
if (node.superClass.type !== 'MemberExpression') {
return false
}
if (node.superClass.property.type !== 'Identifier') {
return false
}
const innerName = node.superClass.property.name
if (innerName === 'Component' || innerName === 'PureComponent') {
return true
}
return false
}
/**
* Extract the parameter name from a node in the AST
*
* @param {Parameter} node
*
* @returns {string} if the name can be resolved, or raised an error if it encounters a node type it doesn't recognize
*/
function getParameterName(node) {
if (node.type === 'Identifier') {
return node.name
}
throw new Error(
`getParameterName could not extract a name from a parameter of type ${node.type} `
)
}
/**
* Extract the parameter type from a node in the AST
*
* @param {Parameter} node
*
* @returns {string} if the type can be resolved, or raised an error if it encounters a node type it doesn't recognize
*/
function getParameterType(node) {
if (node.type !== 'Identifier') {
throw new Error(
`getParameterType could not handle parameter of type ${node.type} - this rule needs to be updated`
)
}
if (node.typeAnnotation && node.typeAnnotation.type === 'TSTypeAnnotation') {
const innerType = node.typeAnnotation.typeAnnotation
if (innerType.type === 'TSStringKeyword') {
return 'string'
} else if (
innerType.type === 'TSTypeReference' &&
innerType.typeName.type === 'Identifier'
) {
return innerType.typeName.name
} else if (
innerType.type === 'TSTypeLiteral' &&
innerType.members.length === 0
) {
// TODO:
// if types are inlined, this needs to do that traversal so we can perform equivalence
// skipping this for now as I'm not aware of usages of this in desktop/desktop
return '{}'
}
}
const typeAnnotation = node.typeAnnotation
? node.typeAnnotation.type
: '(undefined)'
throw new Error(
`getParameterType could not handle parameter ${node.name} with type annotation ${typeAnnotation} - this rule needs to be updated`
)
}
/** @type {RuleModule} */
module.exports = {
meta: {
type: 'problem',
messages: {
emptyParametersExpected: `{{ methodName }} should not accept any parameters`,
unknownParameter: `{{ methodName }} has unknown parameter {{ parameterName }}`,
nameMismatch: `{{ methodName }} has parameter {{ parameterName }} which does not match expected name {{ expectedName }}`,
typeMismatch: `{{ methodName }} has parameter {{ parameterName }} which does not match expected type {{ expectedType }}`,
reservedMethodName: `Method name {{ methodName }} is prohibited as names starting with 'component' or 'shouldComponent' can be confused with React lifecycle methods`,
},
fixable: 'code',
schema: [], // no options
},
create: function (context) {
const filename = context.getFilename()
if (filename.toLowerCase().endsWith('ts')) {
return {}
}
const sourceFile = context.getSourceCode()
/** @param {Node} node */
function getText(node) {
return sourceFile.getText(node)
}
/**
* Verify the provided parameter matches the expected name and type from the React API
*
* @param {string} methodName
* @param {Parameter} node
* @param {{ name: string, type: string }} expectedParameter
*
* @returns {boolean} false if a problem is reported, or true if no issues found with given parameter
*/
function verifyParameter(methodName, node, expectedParameter) {
const parameterName = getParameterName(node)
let isValid = true
if (parameterName !== expectedParameter.name) {
context.report({
node,
messageId: 'nameMismatch',
data: {
methodName,
parameterName,
expectedName: expectedParameter.name,
},
})
isValid = false
}
const parameterTypeName = getParameterType(node)
if (parameterTypeName !== expectedParameter.type) {
context.report({
node,
messageId: 'typeMismatch',
data: {
methodName,
parameterName,
expectedType: expectedParameter.type,
},
})
isValid = false
}
return isValid
}
/**
*
* @param {string} methodName
* @param {MethodDefinition} node
* @param {Array<{name:string,type:string}>} expectedParameters
* @returns
*/
function verifyParameters(methodName, node, expectedParameters) {
// It's okay to omit parameters
for (let i = 0; i < node.value.params.length; i++) {
const parameter = node.value.params[i]
if (i >= expectedParameters.length) {
const parameterName = getParameterName(parameter)
context.report({
node,
messageId: 'unknownParameter',
data: {
methodName,
parameterName,
},
})
return
}
if (!verifyParameter(methodName, parameter, expectedParameters[i])) {
return
}
}
}
let isValidComponent = false
let propsTypeName = '{}'
let stateTypeName = '{}'
return {
ClassDeclaration(node) {
if (!extendsReactComponent(node)) {
return
}
isValidComponent = true
if (!node.superTypeParameters) {
return
}
propsTypeName = getPropsType(node)
stateTypeName = getStateType(node, getText)
},
MethodDefinition(node) {
if (!isValidComponent) {
return
}
if (node.key.type !== 'Identifier') {
return
}
const methodName = node.key.name
if (
methodName.startsWith('component') ||
methodName.startsWith('shouldComponent')
) {
switch (methodName) {
case 'componentWillMount':
case 'componentDidMount':
case 'componentWillUnmount':
if (node.value.params.length) {
context.report({
node,
messageId: 'emptyParametersExpected',
data: {
methodName,
},
})
}
break
case 'componentWillReceiveProps':
return verifyParameters('componentWillReceiveProps', node, [
{ name: 'nextProps', type: propsTypeName },
])
case 'componentWillUpdate':
return verifyParameters('componentWillUpdate', node, [
{ name: 'nextProps', type: propsTypeName },
{ name: 'nextState', type: stateTypeName },
])
case 'componentDidUpdate':
return verifyParameters(methodName, node, [
{ name: 'prevProps', type: propsTypeName },
{ name: 'prevState', type: stateTypeName },
])
case 'shouldComponentUpdate':
return verifyParameters('shouldComponentUpdate', node, [
{ name: 'nextProps', type: propsTypeName },
{ name: 'nextState', type: stateTypeName },
])
default:
context.report({
node,
messageId: 'reservedMethodName',
data: {
methodName,
},
})
}
}
},
}
},
}