mirror of
https://github.com/desktop/desktop
synced 2024-08-25 11:17:29 +00:00
368 lines
10 KiB
JavaScript
368 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,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|