no-array-index-key.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /**
  2. * @fileoverview Prevent usage of Array index in keys
  3. * @author Joe Lencioni
  4. */
  5. 'use strict';
  6. const has = require('object.hasown/polyfill')();
  7. const astUtil = require('../util/ast');
  8. const docsUrl = require('../util/docsUrl');
  9. const pragma = require('../util/pragma');
  10. const report = require('../util/report');
  11. const variableUtil = require('../util/variable');
  12. // ------------------------------------------------------------------------------
  13. // Rule Definition
  14. // ------------------------------------------------------------------------------
  15. function isCreateCloneElement(node, context) {
  16. if (!node) {
  17. return false;
  18. }
  19. if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
  20. return node.object
  21. && node.object.name === pragma.getFromContext(context)
  22. && ['createElement', 'cloneElement'].indexOf(node.property.name) !== -1;
  23. }
  24. if (node.type === 'Identifier') {
  25. const variable = variableUtil.findVariableByName(context, node.name);
  26. if (variable && variable.type === 'ImportSpecifier') {
  27. return variable.parent.source.value === 'react';
  28. }
  29. }
  30. return false;
  31. }
  32. const messages = {
  33. noArrayIndex: 'Do not use Array index in keys',
  34. };
  35. module.exports = {
  36. meta: {
  37. docs: {
  38. description: 'Disallow usage of Array index in keys',
  39. category: 'Best Practices',
  40. recommended: false,
  41. url: docsUrl('no-array-index-key'),
  42. },
  43. messages,
  44. schema: [],
  45. },
  46. create(context) {
  47. // --------------------------------------------------------------------------
  48. // Public
  49. // --------------------------------------------------------------------------
  50. const indexParamNames = [];
  51. const iteratorFunctionsToIndexParamPosition = {
  52. every: 1,
  53. filter: 1,
  54. find: 1,
  55. findIndex: 1,
  56. forEach: 1,
  57. map: 1,
  58. reduce: 2,
  59. reduceRight: 2,
  60. some: 1,
  61. };
  62. function isArrayIndex(node) {
  63. return node.type === 'Identifier'
  64. && indexParamNames.indexOf(node.name) !== -1;
  65. }
  66. function isUsingReactChildren(node) {
  67. const callee = node.callee;
  68. if (
  69. !callee
  70. || !callee.property
  71. || !callee.object
  72. ) {
  73. return null;
  74. }
  75. const isReactChildMethod = ['map', 'forEach'].indexOf(callee.property.name) > -1;
  76. if (!isReactChildMethod) {
  77. return null;
  78. }
  79. const obj = callee.object;
  80. if (obj && obj.name === 'Children') {
  81. return true;
  82. }
  83. if (obj && obj.object && obj.object.name === pragma.getFromContext(context)) {
  84. return true;
  85. }
  86. return false;
  87. }
  88. function getMapIndexParamName(node) {
  89. const callee = node.callee;
  90. if (callee.type !== 'MemberExpression' && callee.type !== 'OptionalMemberExpression') {
  91. return null;
  92. }
  93. if (callee.property.type !== 'Identifier') {
  94. return null;
  95. }
  96. if (!has(iteratorFunctionsToIndexParamPosition, callee.property.name)) {
  97. return null;
  98. }
  99. const callbackArg = isUsingReactChildren(node)
  100. ? node.arguments[1]
  101. : node.arguments[0];
  102. if (!callbackArg) {
  103. return null;
  104. }
  105. if (!astUtil.isFunctionLikeExpression(callbackArg)) {
  106. return null;
  107. }
  108. const params = callbackArg.params;
  109. const indexParamPosition = iteratorFunctionsToIndexParamPosition[callee.property.name];
  110. if (params.length < indexParamPosition + 1) {
  111. return null;
  112. }
  113. return params[indexParamPosition].name;
  114. }
  115. function getIdentifiersFromBinaryExpression(side) {
  116. if (side.type === 'Identifier') {
  117. return side;
  118. }
  119. if (side.type === 'BinaryExpression') {
  120. // recurse
  121. const left = getIdentifiersFromBinaryExpression(side.left);
  122. const right = getIdentifiersFromBinaryExpression(side.right);
  123. return [].concat(left, right).filter(Boolean);
  124. }
  125. return null;
  126. }
  127. function checkPropValue(node) {
  128. if (isArrayIndex(node)) {
  129. // key={bar}
  130. report(context, messages.noArrayIndex, 'noArrayIndex', {
  131. node,
  132. });
  133. return;
  134. }
  135. if (node.type === 'TemplateLiteral') {
  136. // key={`foo-${bar}`}
  137. node.expressions.filter(isArrayIndex).forEach(() => {
  138. report(context, messages.noArrayIndex, 'noArrayIndex', {
  139. node,
  140. });
  141. });
  142. return;
  143. }
  144. if (node.type === 'BinaryExpression') {
  145. // key={'foo' + bar}
  146. const identifiers = getIdentifiersFromBinaryExpression(node);
  147. identifiers.filter(isArrayIndex).forEach(() => {
  148. report(context, messages.noArrayIndex, 'noArrayIndex', {
  149. node,
  150. });
  151. });
  152. return;
  153. }
  154. if (node.type === 'CallExpression'
  155. && node.callee
  156. && node.callee.type === 'MemberExpression'
  157. && node.callee.object
  158. && isArrayIndex(node.callee.object)
  159. && node.callee.property
  160. && node.callee.property.type === 'Identifier'
  161. && node.callee.property.name === 'toString'
  162. ) {
  163. // key={bar.toString()}
  164. report(context, messages.noArrayIndex, 'noArrayIndex', {
  165. node,
  166. });
  167. return;
  168. }
  169. if (node.type === 'CallExpression'
  170. && node.callee
  171. && node.callee.type === 'Identifier'
  172. && node.callee.name === 'String'
  173. && Array.isArray(node.arguments)
  174. && node.arguments.length > 0
  175. && isArrayIndex(node.arguments[0])
  176. ) {
  177. // key={String(bar)}
  178. report(context, messages.noArrayIndex, 'noArrayIndex', {
  179. node: node.arguments[0],
  180. });
  181. }
  182. }
  183. function popIndex(node) {
  184. const mapIndexParamName = getMapIndexParamName(node);
  185. if (!mapIndexParamName) {
  186. return;
  187. }
  188. indexParamNames.pop();
  189. }
  190. return {
  191. 'CallExpression, OptionalCallExpression'(node) {
  192. if (isCreateCloneElement(node.callee, context) && node.arguments.length > 1) {
  193. // React.createElement
  194. if (!indexParamNames.length) {
  195. return;
  196. }
  197. const props = node.arguments[1];
  198. if (props.type !== 'ObjectExpression') {
  199. return;
  200. }
  201. props.properties.forEach((prop) => {
  202. if (!prop.key || prop.key.name !== 'key') {
  203. // { ...foo }
  204. // { foo: bar }
  205. return;
  206. }
  207. checkPropValue(prop.value);
  208. });
  209. return;
  210. }
  211. const mapIndexParamName = getMapIndexParamName(node);
  212. if (!mapIndexParamName) {
  213. return;
  214. }
  215. indexParamNames.push(mapIndexParamName);
  216. },
  217. JSXAttribute(node) {
  218. if (node.name.name !== 'key') {
  219. // foo={bar}
  220. return;
  221. }
  222. if (!indexParamNames.length) {
  223. // Not inside a call expression that we think has an index param.
  224. return;
  225. }
  226. const value = node.value;
  227. if (!value || value.type !== 'JSXExpressionContainer') {
  228. // key='foo' or just simply 'key'
  229. return;
  230. }
  231. checkPropValue(value.expression);
  232. },
  233. 'CallExpression:exit': popIndex,
  234. 'OptionalCallExpression:exit': popIndex,
  235. };
  236. },
  237. };