jsx-key.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /**
  2. * @fileoverview Report missing `key` props in iterators/collection literals.
  3. * @author Ben Mosher
  4. */
  5. 'use strict';
  6. const hasProp = require('jsx-ast-utils/hasProp');
  7. const propName = require('jsx-ast-utils/propName');
  8. const values = require('object.values');
  9. const docsUrl = require('../util/docsUrl');
  10. const pragmaUtil = require('../util/pragma');
  11. const report = require('../util/report');
  12. const astUtil = require('../util/ast');
  13. const getText = require('../util/eslint').getText;
  14. // ------------------------------------------------------------------------------
  15. // Rule Definition
  16. // ------------------------------------------------------------------------------
  17. const defaultOptions = {
  18. checkFragmentShorthand: false,
  19. checkKeyMustBeforeSpread: false,
  20. warnOnDuplicates: false,
  21. };
  22. const messages = {
  23. missingIterKey: 'Missing "key" prop for element in iterator',
  24. missingIterKeyUsePrag: 'Missing "key" prop for element in iterator. Shorthand fragment syntax does not support providing keys. Use {{reactPrag}}.{{fragPrag}} instead',
  25. missingArrayKey: 'Missing "key" prop for element in array',
  26. missingArrayKeyUsePrag: 'Missing "key" prop for element in array. Shorthand fragment syntax does not support providing keys. Use {{reactPrag}}.{{fragPrag}} instead',
  27. keyBeforeSpread: '`key` prop must be placed before any `{...spread}, to avoid conflicting with React’s new JSX transform: https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html`',
  28. nonUniqueKeys: '`key` prop must be unique',
  29. };
  30. /** @type {import('eslint').Rule.RuleModule} */
  31. module.exports = {
  32. meta: {
  33. docs: {
  34. description: 'Disallow missing `key` props in iterators/collection literals',
  35. category: 'Possible Errors',
  36. recommended: true,
  37. url: docsUrl('jsx-key'),
  38. },
  39. messages,
  40. schema: [{
  41. type: 'object',
  42. properties: {
  43. checkFragmentShorthand: {
  44. type: 'boolean',
  45. default: defaultOptions.checkFragmentShorthand,
  46. },
  47. checkKeyMustBeforeSpread: {
  48. type: 'boolean',
  49. default: defaultOptions.checkKeyMustBeforeSpread,
  50. },
  51. warnOnDuplicates: {
  52. type: 'boolean',
  53. default: defaultOptions.warnOnDuplicates,
  54. },
  55. },
  56. additionalProperties: false,
  57. }],
  58. },
  59. create(context) {
  60. const options = Object.assign({}, defaultOptions, context.options[0]);
  61. const checkFragmentShorthand = options.checkFragmentShorthand;
  62. const checkKeyMustBeforeSpread = options.checkKeyMustBeforeSpread;
  63. const warnOnDuplicates = options.warnOnDuplicates;
  64. const reactPragma = pragmaUtil.getFromContext(context);
  65. const fragmentPragma = pragmaUtil.getFragmentFromContext(context);
  66. function isKeyAfterSpread(attributes) {
  67. let hasFoundSpread = false;
  68. return attributes.some((attribute) => {
  69. if (attribute.type === 'JSXSpreadAttribute') {
  70. hasFoundSpread = true;
  71. return false;
  72. }
  73. if (attribute.type !== 'JSXAttribute') {
  74. return false;
  75. }
  76. return hasFoundSpread && propName(attribute) === 'key';
  77. });
  78. }
  79. function checkIteratorElement(node) {
  80. if (node.type === 'JSXElement') {
  81. if (!hasProp(node.openingElement.attributes, 'key')) {
  82. report(context, messages.missingIterKey, 'missingIterKey', { node });
  83. } else {
  84. const attrs = node.openingElement.attributes;
  85. if (checkKeyMustBeforeSpread && isKeyAfterSpread(attrs)) {
  86. report(context, messages.keyBeforeSpread, 'keyBeforeSpread', { node });
  87. }
  88. }
  89. } else if (checkFragmentShorthand && node.type === 'JSXFragment') {
  90. report(context, messages.missingIterKeyUsePrag, 'missingIterKeyUsePrag', {
  91. node,
  92. data: {
  93. reactPrag: reactPragma,
  94. fragPrag: fragmentPragma,
  95. },
  96. });
  97. }
  98. }
  99. function getReturnStatements(node) {
  100. const returnStatements = arguments[1] || [];
  101. if (node.type === 'IfStatement') {
  102. if (node.consequent) {
  103. getReturnStatements(node.consequent, returnStatements);
  104. }
  105. if (node.alternate) {
  106. getReturnStatements(node.alternate, returnStatements);
  107. }
  108. } else if (node.type === 'ReturnStatement') {
  109. returnStatements.push(node);
  110. } else if (Array.isArray(node.body)) {
  111. node.body.forEach((item) => {
  112. if (item.type === 'IfStatement') {
  113. getReturnStatements(item, returnStatements);
  114. }
  115. if (item.type === 'ReturnStatement') {
  116. returnStatements.push(item);
  117. }
  118. });
  119. }
  120. return returnStatements;
  121. }
  122. /**
  123. * Checks if the given node is a function expression or arrow function,
  124. * and checks if there is a missing key prop in return statement's arguments
  125. * @param {ASTNode} node
  126. */
  127. function checkFunctionsBlockStatement(node) {
  128. if (astUtil.isFunctionLikeExpression(node)) {
  129. if (node.body.type === 'BlockStatement') {
  130. getReturnStatements(node.body)
  131. .filter((returnStatement) => returnStatement && returnStatement.argument)
  132. .forEach((returnStatement) => {
  133. checkIteratorElement(returnStatement.argument);
  134. });
  135. }
  136. }
  137. }
  138. /**
  139. * Checks if the given node is an arrow function that has an JSX Element or JSX Fragment in its body,
  140. * and the JSX is missing a key prop
  141. * @param {ASTNode} node
  142. */
  143. function checkArrowFunctionWithJSX(node) {
  144. const isArrFn = node && node.type === 'ArrowFunctionExpression';
  145. const shouldCheckNode = (n) => n && (n.type === 'JSXElement' || n.type === 'JSXFragment');
  146. if (isArrFn && shouldCheckNode(node.body)) {
  147. checkIteratorElement(node.body);
  148. }
  149. if (node.body.type === 'ConditionalExpression') {
  150. if (shouldCheckNode(node.body.consequent)) {
  151. checkIteratorElement(node.body.consequent);
  152. }
  153. if (shouldCheckNode(node.body.alternate)) {
  154. checkIteratorElement(node.body.alternate);
  155. }
  156. } else if (node.body.type === 'LogicalExpression' && shouldCheckNode(node.body.right)) {
  157. checkIteratorElement(node.body.right);
  158. }
  159. }
  160. const childrenToArraySelector = `:matches(
  161. CallExpression
  162. [callee.object.object.name=${reactPragma}]
  163. [callee.object.property.name=Children]
  164. [callee.property.name=toArray],
  165. CallExpression
  166. [callee.object.name=Children]
  167. [callee.property.name=toArray]
  168. )`.replace(/\s/g, '');
  169. let isWithinChildrenToArray = false;
  170. const seen = new WeakSet();
  171. return {
  172. [childrenToArraySelector]() {
  173. isWithinChildrenToArray = true;
  174. },
  175. [`${childrenToArraySelector}:exit`]() {
  176. isWithinChildrenToArray = false;
  177. },
  178. 'ArrayExpression, JSXElement > JSXElement'(node) {
  179. if (isWithinChildrenToArray) {
  180. return;
  181. }
  182. const jsx = (node.type === 'ArrayExpression' ? node.elements : node.parent.children).filter((x) => x && x.type === 'JSXElement');
  183. if (jsx.length === 0) {
  184. return;
  185. }
  186. const map = {};
  187. jsx.forEach((element) => {
  188. const attrs = element.openingElement.attributes;
  189. const keys = attrs.filter((x) => x.name && x.name.name === 'key');
  190. if (keys.length === 0) {
  191. if (node.type === 'ArrayExpression') {
  192. report(context, messages.missingArrayKey, 'missingArrayKey', {
  193. node: element,
  194. });
  195. }
  196. } else {
  197. keys.forEach((attr) => {
  198. const value = getText(context, attr.value);
  199. if (!map[value]) { map[value] = []; }
  200. map[value].push(attr);
  201. if (checkKeyMustBeforeSpread && isKeyAfterSpread(attrs)) {
  202. report(context, messages.keyBeforeSpread, 'keyBeforeSpread', {
  203. node: node.type === 'ArrayExpression' ? node : node.parent,
  204. });
  205. }
  206. });
  207. }
  208. });
  209. if (warnOnDuplicates) {
  210. values(map).filter((v) => v.length > 1).forEach((v) => {
  211. v.forEach((n) => {
  212. if (!seen.has(n)) {
  213. seen.add(n);
  214. report(context, messages.nonUniqueKeys, 'nonUniqueKeys', {
  215. node: n,
  216. });
  217. }
  218. });
  219. });
  220. }
  221. },
  222. JSXFragment(node) {
  223. if (!checkFragmentShorthand || isWithinChildrenToArray) {
  224. return;
  225. }
  226. if (node.parent.type === 'ArrayExpression') {
  227. report(context, messages.missingArrayKeyUsePrag, 'missingArrayKeyUsePrag', {
  228. node,
  229. data: {
  230. reactPrag: reactPragma,
  231. fragPrag: fragmentPragma,
  232. },
  233. });
  234. }
  235. },
  236. // Array.prototype.map
  237. // eslint-disable-next-line no-multi-str
  238. 'CallExpression[callee.type="MemberExpression"][callee.property.name="map"],\
  239. CallExpression[callee.type="OptionalMemberExpression"][callee.property.name="map"],\
  240. OptionalCallExpression[callee.type="MemberExpression"][callee.property.name="map"],\
  241. OptionalCallExpression[callee.type="OptionalMemberExpression"][callee.property.name="map"]'(node) {
  242. if (isWithinChildrenToArray) {
  243. return;
  244. }
  245. const fn = node.arguments.length > 0 && node.arguments[0];
  246. if (!fn || !astUtil.isFunctionLikeExpression(fn)) {
  247. return;
  248. }
  249. checkArrowFunctionWithJSX(fn);
  250. checkFunctionsBlockStatement(fn);
  251. },
  252. // Array.from
  253. 'CallExpression[callee.type="MemberExpression"][callee.property.name="from"]'(node) {
  254. if (isWithinChildrenToArray) {
  255. return;
  256. }
  257. const fn = node.arguments.length > 1 && node.arguments[1];
  258. if (!astUtil.isFunctionLikeExpression(fn)) {
  259. return;
  260. }
  261. checkArrowFunctionWithJSX(fn);
  262. checkFunctionsBlockStatement(fn);
  263. },
  264. };
  265. },
  266. };