no-unstable-nested-components.js 15 KB

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