You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
160 lines
5.4 KiB
160 lines
5.4 KiB
/** |
|
* @fileoverview Ensure symmetric naming of useState hook value and setter variables |
|
* @author Duncan Beevers |
|
*/ |
|
|
|
'use strict'; |
|
|
|
const Components = require('../util/Components'); |
|
const docsUrl = require('../util/docsUrl'); |
|
const report = require('../util/report'); |
|
|
|
// ------------------------------------------------------------------------------ |
|
// Rule Definition |
|
// ------------------------------------------------------------------------------ |
|
|
|
const messages = { |
|
useStateErrorMessage: 'useState call is not destructured into value + setter pair', |
|
}; |
|
|
|
module.exports = { |
|
meta: { |
|
docs: { |
|
description: 'Ensure symmetric naming of useState hook value and setter variables', |
|
category: 'Best Practices', |
|
recommended: false, |
|
url: docsUrl('hook-use-state'), |
|
}, |
|
messages, |
|
schema: [], |
|
type: 'suggestion', |
|
hasSuggestions: true, |
|
}, |
|
|
|
create: Components.detect((context, components, util) => ({ |
|
CallExpression(node) { |
|
const isImmediateReturn = node.parent |
|
&& node.parent.type === 'ReturnStatement'; |
|
|
|
if (isImmediateReturn || !util.isReactHookCall(node, ['useState'])) { |
|
return; |
|
} |
|
|
|
const isDestructuringDeclarator = node.parent |
|
&& node.parent.type === 'VariableDeclarator' |
|
&& node.parent.id.type === 'ArrayPattern'; |
|
|
|
if (!isDestructuringDeclarator) { |
|
report( |
|
context, |
|
messages.useStateErrorMessage, |
|
'useStateErrorMessage', |
|
{ node } |
|
); |
|
return; |
|
} |
|
|
|
const variableNodes = node.parent.id.elements; |
|
const valueVariable = variableNodes[0]; |
|
const setterVariable = variableNodes[1]; |
|
|
|
const valueVariableName = valueVariable |
|
? valueVariable.name |
|
: undefined; |
|
|
|
const setterVariableName = setterVariable |
|
? setterVariable.name |
|
: undefined; |
|
|
|
const caseCandidateMatch = valueVariableName ? valueVariableName.match(/(^[a-z]+)(.*)/) : undefined; |
|
const upperCaseCandidatePrefix = caseCandidateMatch ? caseCandidateMatch[1] : undefined; |
|
const caseCandidateSuffix = caseCandidateMatch ? caseCandidateMatch[2] : undefined; |
|
const expectedSetterVariableNames = upperCaseCandidatePrefix ? [ |
|
`set${upperCaseCandidatePrefix.charAt(0).toUpperCase()}${upperCaseCandidatePrefix.slice(1)}${caseCandidateSuffix}`, |
|
`set${upperCaseCandidatePrefix.toUpperCase()}${caseCandidateSuffix}`, |
|
] : []; |
|
|
|
const isSymmetricGetterSetterPair = valueVariable |
|
&& setterVariable |
|
&& expectedSetterVariableNames.indexOf(setterVariableName) !== -1 |
|
&& variableNodes.length === 2; |
|
|
|
if (!isSymmetricGetterSetterPair) { |
|
const suggestions = [ |
|
{ |
|
desc: 'Destructure useState call into value + setter pair', |
|
fix: (fixer) => { |
|
if (expectedSetterVariableNames.length === 0) { |
|
return; |
|
} |
|
|
|
const fix = fixer.replaceTextRange( |
|
node.parent.id.range, |
|
`[${valueVariableName}, ${expectedSetterVariableNames[0]}]` |
|
); |
|
|
|
return fix; |
|
}, |
|
}, |
|
]; |
|
|
|
const defaultReactImports = components.getDefaultReactImports(); |
|
const defaultReactImportSpecifier = defaultReactImports |
|
? defaultReactImports[0] |
|
: undefined; |
|
|
|
const defaultReactImportName = defaultReactImportSpecifier |
|
? defaultReactImportSpecifier.local.name |
|
: undefined; |
|
|
|
const namedReactImports = components.getNamedReactImports(); |
|
const useStateReactImportSpecifier = namedReactImports |
|
? namedReactImports.find((specifier) => specifier.imported.name === 'useState') |
|
: undefined; |
|
|
|
const isSingleGetter = valueVariable && variableNodes.length === 1; |
|
const isUseStateCalledWithSingleArgument = node.arguments.length === 1; |
|
if (isSingleGetter && isUseStateCalledWithSingleArgument) { |
|
const useMemoReactImportSpecifier = namedReactImports |
|
&& namedReactImports.find((specifier) => specifier.imported.name === 'useMemo'); |
|
|
|
let useMemoCode; |
|
if (useMemoReactImportSpecifier) { |
|
useMemoCode = useMemoReactImportSpecifier.local.name; |
|
} else if (defaultReactImportName) { |
|
useMemoCode = `${defaultReactImportName}.useMemo`; |
|
} else { |
|
useMemoCode = 'useMemo'; |
|
} |
|
|
|
suggestions.unshift({ |
|
desc: 'Replace useState call with useMemo', |
|
fix: (fixer) => [ |
|
// Add useMemo import, if necessary |
|
useStateReactImportSpecifier |
|
&& (!useMemoReactImportSpecifier || defaultReactImportName) |
|
&& fixer.insertTextAfter(useStateReactImportSpecifier, ', useMemo'), |
|
// Convert single-value destructure to simple assignment |
|
fixer.replaceTextRange(node.parent.id.range, valueVariableName), |
|
// Convert useState call to useMemo + arrow function + dependency array |
|
fixer.replaceTextRange( |
|
node.range, |
|
`${useMemoCode}(() => ${context.getSourceCode().getText(node.arguments[0])}, [])` |
|
), |
|
].filter(Boolean), |
|
}); |
|
} |
|
|
|
report( |
|
context, |
|
messages.useStateErrorMessage, |
|
'useStateErrorMessage', |
|
{ |
|
node: node.parent.id, |
|
suggest: suggestions, |
|
} |
|
); |
|
} |
|
}, |
|
})), |
|
};
|
|
|