no-unstable-nested-components.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. /**
  2. * @fileoverview Prevent creating unstable components inside components
  3. * @author Ari Perkkiö
  4. */
  5. 'use strict';
  6. const Components = require('../util/Components');
  7. const docsUrl = require('../util/docsUrl');
  8. // ------------------------------------------------------------------------------
  9. // Constants
  10. // ------------------------------------------------------------------------------
  11. const ERROR_MESSAGE_WITHOUT_NAME = 'Declare this component outside parent component or memoize it.';
  12. const COMPONENT_AS_PROPS_INFO = ' If you want to allow component creation in props, set allowAsProps option to true.';
  13. const HOOK_REGEXP = /^use[A-Z0-9].*$/;
  14. // ------------------------------------------------------------------------------
  15. // Helpers
  16. // ------------------------------------------------------------------------------
  17. /**
  18. * Generate error message with given parent component name
  19. * @param {String} parentName Name of the parent component
  20. * @returns {String} Error message with parent component name
  21. */
  22. function generateErrorMessageWithParentName(parentName) {
  23. return `Declare this component outside parent component "${parentName}" or memoize it.`;
  24. }
  25. /**
  26. * Check whether given text starts with `render`. Comparison is case-sensitive.
  27. * @param {String} text Text to validate
  28. * @returns {Boolean}
  29. */
  30. function startsWithRender(text) {
  31. return (text || '').startsWith('render');
  32. }
  33. /**
  34. * Get closest parent matching given matcher
  35. * @param {ASTNode} node The AST node
  36. * @param {Function} matcher Method used to match the parent
  37. * @returns {ASTNode} The matching parent node, if any
  38. */
  39. function getClosestMatchingParent(node, matcher) {
  40. if (!node || !node.parent || node.parent.type === 'Program') {
  41. return;
  42. }
  43. if (matcher(node.parent)) {
  44. return node.parent;
  45. }
  46. return getClosestMatchingParent(node.parent, matcher);
  47. }
  48. /**
  49. * Matcher used to check whether given node is a `createElement` call
  50. * @param {ASTNode} node The AST node
  51. * @returns {Boolean} True if node is a `createElement` call, false if not
  52. */
  53. function isCreateElementMatcher(node) {
  54. return (
  55. node
  56. && node.type === 'CallExpression'
  57. && node.callee
  58. && node.callee.property
  59. && node.callee.property.name === 'createElement'
  60. );
  61. }
  62. /**
  63. * Matcher used to check whether given node is a `ObjectExpression`
  64. * @param {ASTNode} node The AST node
  65. * @returns {Boolean} True if node is a `ObjectExpression`, false if not
  66. */
  67. function isObjectExpressionMatcher(node) {
  68. return node && node.type === 'ObjectExpression';
  69. }
  70. /**
  71. * Matcher used to check whether given node is a `JSXExpressionContainer`
  72. * @param {ASTNode} node The AST node
  73. * @returns {Boolean} True if node is a `JSXExpressionContainer`, false if not
  74. */
  75. function isJSXExpressionContainerMatcher(node) {
  76. return node && node.type === 'JSXExpressionContainer';
  77. }
  78. /**
  79. * Matcher used to check whether given node is a `JSXAttribute` of `JSXExpressionContainer`
  80. * @param {ASTNode} node The AST node
  81. * @returns {Boolean} True if node is a `JSXAttribute` of `JSXExpressionContainer`, false if not
  82. */
  83. function isJSXAttributeOfExpressionContainerMatcher(node) {
  84. return (
  85. node
  86. && node.type === 'JSXAttribute'
  87. && node.value
  88. && node.value.type === 'JSXExpressionContainer'
  89. );
  90. }
  91. /**
  92. * Matcher used to check whether given node is a `CallExpression`
  93. * @param {ASTNode} node The AST node
  94. * @returns {Boolean} True if node is a `CallExpression`, false if not
  95. */
  96. function isCallExpressionMatcher(node) {
  97. return node && node.type === 'CallExpression';
  98. }
  99. /**
  100. * Check whether given node or its parent is directly inside `map` call
  101. * ```jsx
  102. * {items.map(item => <li />)}
  103. * ```
  104. * @param {ASTNode} node The AST node
  105. * @returns {Boolean} True if node is directly inside `map` call, false if not
  106. */
  107. function isMapCall(node) {
  108. return (
  109. node
  110. && node.callee
  111. && node.callee.property
  112. && node.callee.property.name === 'map'
  113. );
  114. }
  115. /**
  116. * Check whether given node is `ReturnStatement` of a React hook
  117. * @param {ASTNode} node The AST node
  118. * @returns {Boolean} True if node is a `ReturnStatement` of a React hook, false if not
  119. */
  120. function isReturnStatementOfHook(node) {
  121. if (
  122. !node
  123. || !node.parent
  124. || node.parent.type !== 'ReturnStatement'
  125. ) {
  126. return false;
  127. }
  128. const callExpression = getClosestMatchingParent(node, isCallExpressionMatcher);
  129. return (
  130. callExpression
  131. && callExpression.callee
  132. && HOOK_REGEXP.test(callExpression.callee.name)
  133. );
  134. }
  135. /**
  136. * Check whether given node is declared inside a render prop
  137. * ```jsx
  138. * <Component renderFooter={() => <div />} />
  139. * <Component>{() => <div />}</Component>
  140. * ```
  141. * @param {ASTNode} node The AST node
  142. * @returns {Boolean} True if component is declared inside a render prop, false if not
  143. */
  144. function isComponentInRenderProp(node) {
  145. if (
  146. node
  147. && node.parent
  148. && node.parent.type === 'Property'
  149. && node.parent.key
  150. && startsWithRender(node.parent.key.name)
  151. ) {
  152. return true;
  153. }
  154. // Check whether component is a render prop used as direct children, e.g. <Component>{() => <div />}</Component>
  155. if (
  156. node
  157. && node.parent
  158. && node.parent.type === 'JSXExpressionContainer'
  159. && node.parent.parent
  160. && node.parent.parent.type === 'JSXElement'
  161. ) {
  162. return true;
  163. }
  164. const jsxExpressionContainer = getClosestMatchingParent(node, isJSXExpressionContainerMatcher);
  165. // Check whether prop name indicates accepted patterns
  166. if (
  167. jsxExpressionContainer
  168. && jsxExpressionContainer.parent
  169. && jsxExpressionContainer.parent.type === 'JSXAttribute'
  170. && jsxExpressionContainer.parent.name
  171. && jsxExpressionContainer.parent.name.type === 'JSXIdentifier'
  172. ) {
  173. const propName = jsxExpressionContainer.parent.name.name;
  174. // Starts with render, e.g. <Component renderFooter={() => <div />} />
  175. if (startsWithRender(propName)) {
  176. return true;
  177. }
  178. // Uses children prop explicitly, e.g. <Component children={() => <div />} />
  179. if (propName === 'children') {
  180. return true;
  181. }
  182. }
  183. return false;
  184. }
  185. /**
  186. * Check whether given node is declared directly inside a render property
  187. * ```jsx
  188. * const rows = { render: () => <div /> }
  189. * <Component rows={ [{ render: () => <div /> }] } />
  190. * ```
  191. * @param {ASTNode} node The AST node
  192. * @returns {Boolean} True if component is declared inside a render property, false if not
  193. */
  194. function isDirectValueOfRenderProperty(node) {
  195. return (
  196. node
  197. && node.parent
  198. && node.parent.type === 'Property'
  199. && node.parent.key
  200. && node.parent.key.type === 'Identifier'
  201. && startsWithRender(node.parent.key.name)
  202. );
  203. }
  204. /**
  205. * Resolve the component name of given node
  206. * @param {ASTNode} node The AST node of the component
  207. * @returns {String} Name of the component, if any
  208. */
  209. function resolveComponentName(node) {
  210. const parentName = node.id && node.id.name;
  211. if (parentName) return parentName;
  212. return (
  213. node.type === 'ArrowFunctionExpression'
  214. && node.parent
  215. && node.parent.id
  216. && node.parent.id.name
  217. );
  218. }
  219. // ------------------------------------------------------------------------------
  220. // Rule Definition
  221. // ------------------------------------------------------------------------------
  222. module.exports = {
  223. meta: {
  224. docs: {
  225. description: 'Prevent creating unstable components inside components',
  226. category: 'Possible Errors',
  227. recommended: false,
  228. url: docsUrl('no-unstable-nested-components')
  229. },
  230. schema: [{
  231. type: 'object',
  232. properties: {
  233. customValidators: {
  234. type: 'array',
  235. items: {
  236. type: 'string'
  237. }
  238. },
  239. allowAsProps: {
  240. type: 'boolean'
  241. }
  242. },
  243. additionalProperties: false
  244. }]
  245. },
  246. create: Components.detect((context, components, utils) => {
  247. const allowAsProps = context.options.some((option) => option && option.allowAsProps);
  248. /**
  249. * Check whether given node is declared inside class component's render block
  250. * ```jsx
  251. * class Component extends React.Component {
  252. * render() {
  253. * class NestedClassComponent extends React.Component {
  254. * ...
  255. * ```
  256. * @param {ASTNode} node The AST node being checked
  257. * @returns {Boolean} True if node is inside class component's render block, false if not
  258. */
  259. function isInsideRenderMethod(node) {
  260. const parentComponent = utils.getParentComponent();
  261. if (!parentComponent || parentComponent.type !== 'ClassDeclaration') {
  262. return false;
  263. }
  264. return (
  265. node
  266. && node.parent
  267. && node.parent.type === 'MethodDefinition'
  268. && node.parent.key
  269. && node.parent.key.name === 'render'
  270. );
  271. }
  272. /**
  273. * Check whether given node is a function component declared inside class component.
  274. * Util's component detection fails to detect function components inside class components.
  275. * ```jsx
  276. * class Component extends React.Component {
  277. * render() {
  278. * const NestedComponent = () => <div />;
  279. * ...
  280. * ```
  281. * @param {ASTNode} node The AST node being checked
  282. * @returns {Boolean} True if given node a function component declared inside class component, false if not
  283. */
  284. function isFunctionComponentInsideClassComponent(node) {
  285. const parentComponent = utils.getParentComponent();
  286. const parentStatelessComponent = utils.getParentStatelessComponent();
  287. return (
  288. parentComponent
  289. && parentStatelessComponent
  290. && parentComponent.type === 'ClassDeclaration'
  291. && utils.getStatelessComponent(parentStatelessComponent)
  292. && utils.isReturningJSX(node)
  293. );
  294. }
  295. /**
  296. * Check whether given node is declared inside `createElement` call's props
  297. * ```js
  298. * React.createElement(Component, {
  299. * footer: () => React.createElement("div", null)
  300. * })
  301. * ```
  302. * @param {ASTNode} node The AST node
  303. * @returns {Boolean} True if node is declare inside `createElement` call's props, false if not
  304. */
  305. function isComponentInsideCreateElementsProp(node) {
  306. if (!components.get(node)) {
  307. return false;
  308. }
  309. const createElementParent = getClosestMatchingParent(node, isCreateElementMatcher);
  310. return (
  311. createElementParent
  312. && createElementParent.arguments
  313. && createElementParent.arguments[1] === getClosestMatchingParent(node, isObjectExpressionMatcher)
  314. );
  315. }
  316. /**
  317. * Check whether given node is declared inside a component prop.
  318. * ```jsx
  319. * <Component footer={() => <div />} />
  320. * ```
  321. * @param {ASTNode} node The AST node being checked
  322. * @returns {Boolean} True if node is a component declared inside prop, false if not
  323. */
  324. function isComponentInProp(node) {
  325. const jsxAttribute = getClosestMatchingParent(node, isJSXAttributeOfExpressionContainerMatcher);
  326. if (!jsxAttribute) {
  327. return isComponentInsideCreateElementsProp(node);
  328. }
  329. return utils.isReturningJSX(node);
  330. }
  331. /**
  332. * Check whether given node is a stateless component returning non-JSX
  333. * ```jsx
  334. * {{ a: () => null }}
  335. * ```
  336. * @param {ASTNode} node The AST node being checked
  337. * @returns {Boolean} True if node is a stateless component returning non-JSX, false if not
  338. */
  339. function isStatelessComponentReturningNull(node) {
  340. const component = utils.getStatelessComponent(node);
  341. return component && !utils.isReturningJSX(component);
  342. }
  343. /**
  344. * Check whether given node is a unstable nested component
  345. * @param {ASTNode} node The AST node being checked
  346. */
  347. function validate(node) {
  348. if (!node || !node.parent) {
  349. return;
  350. }
  351. const isDeclaredInsideProps = isComponentInProp(node);
  352. if (
  353. !components.get(node)
  354. && !isFunctionComponentInsideClassComponent(node)
  355. && !isDeclaredInsideProps) {
  356. return;
  357. }
  358. if (
  359. // Support allowAsProps option
  360. (isDeclaredInsideProps && (allowAsProps || isComponentInRenderProp(node)))
  361. // Prevent reporting components created inside Array.map calls
  362. || isMapCall(node)
  363. || isMapCall(node.parent)
  364. // Do not mark components declared inside hooks (or falsly '() => null' clean-up methods)
  365. || isReturnStatementOfHook(node)
  366. // Do not mark objects containing render methods
  367. || isDirectValueOfRenderProperty(node)
  368. // Prevent reporting nested class components twice
  369. || isInsideRenderMethod(node)
  370. // Prevent falsely reporting deteceted "components" which do not return JSX
  371. || isStatelessComponentReturningNull(node)
  372. ) {
  373. return;
  374. }
  375. // Get the closest parent component
  376. const parentComponent = getClosestMatchingParent(
  377. node,
  378. (nodeToMatch) => components.get(nodeToMatch)
  379. );
  380. if (parentComponent) {
  381. const parentName = resolveComponentName(parentComponent);
  382. // Exclude lowercase parents, e.g. function createTestComponent()
  383. // React-dom prevents creating lowercase components
  384. if (parentName && parentName[0] === parentName[0].toLowerCase()) {
  385. return;
  386. }
  387. let message = parentName
  388. ? generateErrorMessageWithParentName(parentName)
  389. : ERROR_MESSAGE_WITHOUT_NAME;
  390. // Add information about allowAsProps option when component is declared inside prop
  391. if (isDeclaredInsideProps && !allowAsProps) {
  392. message += COMPONENT_AS_PROPS_INFO;
  393. }
  394. context.report({node, message});
  395. }
  396. }
  397. // --------------------------------------------------------------------------
  398. // Public
  399. // --------------------------------------------------------------------------
  400. return {
  401. FunctionDeclaration(node) { validate(node); },
  402. ArrowFunctionExpression(node) { validate(node); },
  403. FunctionExpression(node) { validate(node); },
  404. ClassDeclaration(node) { validate(node); }
  405. };
  406. })
  407. };