yannickcr/eslint-plugin-react

View on GitHub
lib/rules/no-array-index-key.js

Summary

Maintainability
D
3 days
Test Coverage
/**
 * @fileoverview Prevent usage of Array index in keys
 * @author Joe Lencioni
 */

'use strict';

const has = require('object.hasown/polyfill')();
const astUtil = require('../util/ast');
const docsUrl = require('../util/docsUrl');
const pragma = require('../util/pragma');
const report = require('../util/report');
const variableUtil = require('../util/variable');

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------

function isCreateCloneElement(node, context) {
  if (!node) {
    return false;
  }

  if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
    return node.object
      && node.object.name === pragma.getFromContext(context)
      && ['createElement', 'cloneElement'].indexOf(node.property.name) !== -1;
  }

  if (node.type === 'Identifier') {
    const variable = variableUtil.findVariableByName(context, node.name);
    if (variable && variable.type === 'ImportSpecifier') {
      return variable.parent.source.value === 'react';
    }
  }

  return false;
}

const messages = {
  noArrayIndex: 'Do not use Array index in keys',
};

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    docs: {
      description: 'Disallow usage of Array index in keys',
      category: 'Best Practices',
      recommended: false,
      url: docsUrl('no-array-index-key'),
    },

    messages,

    schema: [],
  },

  create(context) {
    // --------------------------------------------------------------------------
    // Public
    // --------------------------------------------------------------------------
    const indexParamNames = [];
    const iteratorFunctionsToIndexParamPosition = {
      every: 1,
      filter: 1,
      find: 1,
      findIndex: 1,
      flatMap: 1,
      forEach: 1,
      map: 1,
      reduce: 2,
      reduceRight: 2,
      some: 1,
    };

    function isArrayIndex(node) {
      return node.type === 'Identifier'
        && indexParamNames.indexOf(node.name) !== -1;
    }

    function isUsingReactChildren(node) {
      const callee = node.callee;
      if (
        !callee
        || !callee.property
        || !callee.object
      ) {
        return null;
      }

      const isReactChildMethod = ['map', 'forEach'].indexOf(callee.property.name) > -1;
      if (!isReactChildMethod) {
        return null;
      }

      const obj = callee.object;
      if (obj && obj.name === 'Children') {
        return true;
      }
      if (obj && obj.object && obj.object.name === pragma.getFromContext(context)) {
        return true;
      }

      return false;
    }

    function getMapIndexParamName(node) {
      const callee = node.callee;
      if (callee.type !== 'MemberExpression' && callee.type !== 'OptionalMemberExpression') {
        return null;
      }
      if (callee.property.type !== 'Identifier') {
        return null;
      }
      if (!has(iteratorFunctionsToIndexParamPosition, callee.property.name)) {
        return null;
      }

      const callbackArg = isUsingReactChildren(node)
        ? node.arguments[1]
        : node.arguments[0];

      if (!callbackArg) {
        return null;
      }

      if (!astUtil.isFunctionLikeExpression(callbackArg)) {
        return null;
      }

      const params = callbackArg.params;

      const indexParamPosition = iteratorFunctionsToIndexParamPosition[callee.property.name];
      if (params.length < indexParamPosition + 1) {
        return null;
      }

      return params[indexParamPosition].name;
    }

    function getIdentifiersFromBinaryExpression(side) {
      if (side.type === 'Identifier') {
        return side;
      }

      if (side.type === 'BinaryExpression') {
        // recurse
        const left = getIdentifiersFromBinaryExpression(side.left);
        const right = getIdentifiersFromBinaryExpression(side.right);
        return [].concat(left, right).filter(Boolean);
      }

      return null;
    }

    function checkPropValue(node) {
      if (isArrayIndex(node)) {
        // key={bar}
        report(context, messages.noArrayIndex, 'noArrayIndex', {
          node,
        });
        return;
      }

      if (node.type === 'TemplateLiteral') {
        // key={`foo-${bar}`}
        node.expressions.filter(isArrayIndex).forEach(() => {
          report(context, messages.noArrayIndex, 'noArrayIndex', {
            node,
          });
        });

        return;
      }

      if (node.type === 'BinaryExpression') {
        // key={'foo' + bar}
        const identifiers = getIdentifiersFromBinaryExpression(node);

        identifiers.filter(isArrayIndex).forEach(() => {
          report(context, messages.noArrayIndex, 'noArrayIndex', {
            node,
          });
        });

        return;
      }

      if (node.type === 'CallExpression'
          && node.callee
          && node.callee.type === 'MemberExpression'
          && node.callee.object
          && isArrayIndex(node.callee.object)
          && node.callee.property
          && node.callee.property.type === 'Identifier'
          && node.callee.property.name === 'toString'
      ) {
        // key={bar.toString()}
        report(context, messages.noArrayIndex, 'noArrayIndex', {
          node,
        });
        return;
      }

      if (node.type === 'CallExpression'
          && node.callee
          && node.callee.type === 'Identifier'
          && node.callee.name === 'String'
          && Array.isArray(node.arguments)
          && node.arguments.length > 0
          && isArrayIndex(node.arguments[0])
      ) {
        // key={String(bar)}
        report(context, messages.noArrayIndex, 'noArrayIndex', {
          node: node.arguments[0],
        });
      }
    }

    function popIndex(node) {
      const mapIndexParamName = getMapIndexParamName(node);
      if (!mapIndexParamName) {
        return;
      }

      indexParamNames.pop();
    }

    return {
      'CallExpression, OptionalCallExpression'(node) {
        if (isCreateCloneElement(node.callee, context) && node.arguments.length > 1) {
          // React.createElement
          if (!indexParamNames.length) {
            return;
          }

          const props = node.arguments[1];

          if (props.type !== 'ObjectExpression') {
            return;
          }

          props.properties.forEach((prop) => {
            if (!prop.key || prop.key.name !== 'key') {
              // { ...foo }
              // { foo: bar }
              return;
            }

            checkPropValue(prop.value);
          });

          return;
        }

        const mapIndexParamName = getMapIndexParamName(node);
        if (!mapIndexParamName) {
          return;
        }

        indexParamNames.push(mapIndexParamName);
      },

      JSXAttribute(node) {
        if (node.name.name !== 'key') {
          // foo={bar}
          return;
        }

        if (!indexParamNames.length) {
          // Not inside a call expression that we think has an index param.
          return;
        }

        const value = node.value;
        if (!value || value.type !== 'JSXExpressionContainer') {
          // key='foo' or just simply 'key'
          return;
        }

        checkPropValue(value.expression);
      },

      'CallExpression:exit': popIndex,
      'OptionalCallExpression:exit': popIndex,
    };
  },
};