v-if-else-key.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. /**
  2. * @author Felipe Melendez
  3. * See LICENSE file in root directory for full license.
  4. */
  5. 'use strict'
  6. // =============================================================================
  7. // Requirements
  8. // =============================================================================
  9. const utils = require('../utils')
  10. const casing = require('../utils/casing')
  11. // =============================================================================
  12. // Rule Helpers
  13. // =============================================================================
  14. /**
  15. * A conditional family is made up of a group of repeated components that are conditionally rendered
  16. * using v-if, v-else-if, and v-else.
  17. *
  18. * @typedef {Object} ConditionalFamily
  19. * @property {VElement} if - The node associated with the 'v-if' directive.
  20. * @property {VElement[]} elseIf - An array of nodes associated with 'v-else-if' directives.
  21. * @property {VElement | null} else - The node associated with the 'v-else' directive, or null if there isn't one.
  22. */
  23. /**
  24. * Checks if a given node has sibling nodes of the same type that are also conditionally rendered.
  25. * This is used to determine if multiple instances of the same component are being conditionally
  26. * rendered within the same parent scope.
  27. *
  28. * @param {VElement} node - The Vue component node to check for conditional rendering siblings.
  29. * @param {string} componentName - The name of the component to check for sibling instances.
  30. * @returns {boolean} True if there are sibling nodes of the same type and conditionally rendered, false otherwise.
  31. */
  32. const hasConditionalRenderedSiblings = (node, componentName) => {
  33. if (!node.parent || node.parent.type !== 'VElement') {
  34. return false
  35. }
  36. return node.parent.children.some(
  37. (sibling) =>
  38. sibling !== node &&
  39. sibling.type === 'VElement' &&
  40. sibling.rawName === componentName &&
  41. hasConditionalDirective(sibling)
  42. )
  43. }
  44. /**
  45. * Checks for the presence of a 'key' attribute in the given node. If the 'key' attribute is missing
  46. * and the node is part of a conditional family a report is generated.
  47. * The fix proposed adds a unique key based on the component's name and count,
  48. * following the format '${kebabCase(componentName)}-${componentCount}', e.g., 'some-component-2'.
  49. *
  50. * @param {VElement} node - The Vue component node to check for a 'key' attribute.
  51. * @param {RuleContext} context - The rule's context object, used for reporting.
  52. * @param {string} componentName - Name of the component.
  53. * @param {string} uniqueKey - A unique key for the repeated component, used for the fix.
  54. * @param {Map<VElement, ConditionalFamily>} conditionalFamilies - Map of conditionally rendered components and their respective conditional directives.
  55. */
  56. const checkForKey = (
  57. node,
  58. context,
  59. componentName,
  60. uniqueKey,
  61. conditionalFamilies
  62. ) => {
  63. if (
  64. !node.parent ||
  65. node.parent.type !== 'VElement' ||
  66. !hasConditionalRenderedSiblings(node, componentName)
  67. ) {
  68. return
  69. }
  70. const conditionalFamily = conditionalFamilies.get(node.parent)
  71. if (!conditionalFamily || utils.hasAttribute(node, 'key')) {
  72. return
  73. }
  74. const needsKey =
  75. conditionalFamily.if === node ||
  76. conditionalFamily.else === node ||
  77. conditionalFamily.elseIf.includes(node)
  78. if (needsKey) {
  79. context.report({
  80. node: node.startTag,
  81. loc: node.startTag.loc,
  82. messageId: 'requireKey',
  83. data: { componentName },
  84. fix(fixer) {
  85. const afterComponentNamePosition =
  86. node.startTag.range[0] + componentName.length + 1
  87. return fixer.insertTextBeforeRange(
  88. [afterComponentNamePosition, afterComponentNamePosition],
  89. ` key="${uniqueKey}"`
  90. )
  91. }
  92. })
  93. }
  94. }
  95. /**
  96. * Checks for the presence of conditional directives in the given node.
  97. *
  98. * @param {VElement} node - The node to check for conditional directives.
  99. * @returns {boolean} Returns true if a conditional directive is found in the node or its parents,
  100. * false otherwise.
  101. */
  102. const hasConditionalDirective = (node) =>
  103. utils.hasDirective(node, 'if') ||
  104. utils.hasDirective(node, 'else-if') ||
  105. utils.hasDirective(node, 'else')
  106. // =============================================================================
  107. // Rule Definition
  108. // =============================================================================
  109. /** @type {import('eslint').Rule.RuleModule} */
  110. module.exports = {
  111. meta: {
  112. type: 'problem',
  113. docs: {
  114. description:
  115. 'require key attribute for conditionally rendered repeated components',
  116. categories: null,
  117. recommended: false,
  118. url: 'https://eslint.vuejs.org/rules/v-if-else-key.html'
  119. },
  120. // eslint-disable-next-line eslint-plugin/require-meta-fixable -- fixer is not recognized
  121. fixable: 'code',
  122. schema: [],
  123. messages: {
  124. requireKey:
  125. "Conditionally rendered repeated component '{{componentName}}' expected to have a 'key' attribute."
  126. }
  127. },
  128. /**
  129. * Creates and returns a rule object which checks usage of repeated components. If a component
  130. * is used more than once, it checks for the presence of a key.
  131. *
  132. * @param {RuleContext} context - The context object.
  133. * @returns {Object} A dictionary of functions to be called on traversal of the template body by
  134. * the eslint parser.
  135. */
  136. create(context) {
  137. /**
  138. * Map to store conditionally rendered components and their respective conditional directives.
  139. *
  140. * @type {Map<VElement, ConditionalFamily>}
  141. */
  142. const conditionalFamilies = new Map()
  143. /**
  144. * Array of Maps to keep track of components and their usage counts along with the first
  145. * node instance. Each Map represents a different scope level, and maps a component name to
  146. * an object containing the count and a reference to the first node.
  147. */
  148. /** @type {Map<string, { count: number; firstNode: any }>[]} */
  149. const componentUsageStack = [new Map()]
  150. /**
  151. * Checks if a given node represents a custom component without any conditional directives.
  152. *
  153. * @param {VElement} node - The AST node to check.
  154. * @returns {boolean} True if the node represents a custom component without any conditional directives, false otherwise.
  155. */
  156. const isCustomComponentWithoutCondition = (node) =>
  157. node.type === 'VElement' &&
  158. utils.isCustomComponent(node) &&
  159. !hasConditionalDirective(node)
  160. /** Set of built-in Vue components that are exempt from the rule. */
  161. /** @type {Set<string>} */
  162. const exemptTags = new Set(['component', 'slot', 'template'])
  163. /** Set to keep track of nodes we've pushed to the stack. */
  164. /** @type {Set<any>} */
  165. const pushedNodes = new Set()
  166. /**
  167. * Creates and returns an object representing a conditional family.
  168. *
  169. * @param {VElement} ifNode - The VElement associated with the 'v-if' directive.
  170. * @returns {ConditionalFamily}
  171. */
  172. const createConditionalFamily = (ifNode) => ({
  173. if: ifNode,
  174. elseIf: [],
  175. else: null
  176. })
  177. return utils.defineTemplateBodyVisitor(context, {
  178. /**
  179. * Callback to be executed when a Vue element is traversed. This function checks if the
  180. * element is a component, increments the usage count of the component in the
  181. * current scope, and checks for the key directive if the component is repeated.
  182. *
  183. * @param {VElement} node - The traversed Vue element.
  184. */
  185. VElement(node) {
  186. if (exemptTags.has(node.rawName)) {
  187. return
  188. }
  189. const condition =
  190. utils.getDirective(node, 'if') ||
  191. utils.getDirective(node, 'else-if') ||
  192. utils.getDirective(node, 'else')
  193. if (condition) {
  194. const conditionType = condition.key.name.name
  195. if (node.parent && node.parent.type === 'VElement') {
  196. let conditionalFamily = conditionalFamilies.get(node.parent)
  197. if (!conditionalFamily) {
  198. conditionalFamily = createConditionalFamily(node)
  199. conditionalFamilies.set(node.parent, conditionalFamily)
  200. }
  201. if (conditionalFamily) {
  202. switch (conditionType) {
  203. case 'if': {
  204. conditionalFamily = createConditionalFamily(node)
  205. conditionalFamilies.set(node.parent, conditionalFamily)
  206. break
  207. }
  208. case 'else-if': {
  209. conditionalFamily.elseIf.push(node)
  210. break
  211. }
  212. case 'else': {
  213. conditionalFamily.else = node
  214. break
  215. }
  216. }
  217. }
  218. }
  219. }
  220. if (isCustomComponentWithoutCondition(node)) {
  221. componentUsageStack.push(new Map())
  222. return
  223. }
  224. if (!utils.isCustomComponent(node)) {
  225. return
  226. }
  227. const componentName = node.rawName
  228. const currentScope = componentUsageStack[componentUsageStack.length - 1]
  229. const usageInfo = currentScope.get(componentName) || {
  230. count: 0,
  231. firstNode: null
  232. }
  233. if (hasConditionalDirective(node)) {
  234. // Store the first node if this is the first occurrence
  235. if (usageInfo.count === 0) {
  236. usageInfo.firstNode = node
  237. }
  238. if (usageInfo.count > 0) {
  239. const uniqueKey = `${casing.kebabCase(componentName)}-${
  240. usageInfo.count + 1
  241. }`
  242. checkForKey(
  243. node,
  244. context,
  245. componentName,
  246. uniqueKey,
  247. conditionalFamilies
  248. )
  249. // If this is the second occurrence, also apply a fix to the first occurrence
  250. if (usageInfo.count === 1) {
  251. const uniqueKeyForFirstInstance = `${casing.kebabCase(
  252. componentName
  253. )}-1`
  254. checkForKey(
  255. usageInfo.firstNode,
  256. context,
  257. componentName,
  258. uniqueKeyForFirstInstance,
  259. conditionalFamilies
  260. )
  261. }
  262. }
  263. usageInfo.count += 1
  264. currentScope.set(componentName, usageInfo)
  265. }
  266. componentUsageStack.push(new Map())
  267. pushedNodes.add(node)
  268. },
  269. 'VElement:exit'(node) {
  270. if (exemptTags.has(node.rawName)) {
  271. return
  272. }
  273. if (isCustomComponentWithoutCondition(node)) {
  274. componentUsageStack.pop()
  275. return
  276. }
  277. if (!utils.isCustomComponent(node)) {
  278. return
  279. }
  280. if (pushedNodes.has(node)) {
  281. componentUsageStack.pop()
  282. pushedNodes.delete(node)
  283. }
  284. }
  285. })
  286. }
  287. }