/** * @fileoverview disallow mutation component props * @author 2018 Armano */ 'use strict' /** * @typedef {{name?: string, set: Set}} PropsInfo */ const utils = require('../utils') const { findVariable } = require('@eslint-community/eslint-utils') // https://github.com/vuejs/vue-next/blob/7c11c58faf8840ab97b6449c98da0296a60dddd8/packages/shared/src/globalsWhitelist.ts const GLOBALS_WHITE_LISTED = new Set([ 'Infinity', 'undefined', 'NaN', 'isFinite', 'isNaN', 'parseFloat', 'parseInt', 'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'Math', 'Number', 'Date', 'Array', 'Object', 'Boolean', 'String', 'RegExp', 'Map', 'Set', 'JSON', 'Intl', 'BigInt' ]) /** * @param {ASTNode} node * @returns {VExpressionContainer} */ function getVExpressionContainer(node) { let n = node while (n.type !== 'VExpressionContainer') { n = /** @type {ASTNode} */ (n.parent) } return n } /** * @param {ASTNode} node * @returns {node is Identifier} */ function isVmReference(node) { if (node.type !== 'Identifier') { return false } const parent = node.parent if (parent.type === 'MemberExpression') { if (parent.property === node) { // foo.id return false } } else if ( parent.type === 'Property' && parent.key === node && !parent.computed ) { // {id: foo} return false } const exprContainer = getVExpressionContainer(node) for (const reference of exprContainer.references) { if (reference.variable != null) { // Not vm reference continue } if (reference.id === node) { return true } } return false } /** * @param { object } options * @param { boolean } options.shallowOnly Enables mutating the value of a prop but leaving the reference the same */ function parseOptions(options) { return Object.assign( { shallowOnly: false }, options ) } module.exports = { meta: { type: 'suggestion', docs: { description: 'disallow mutation of component props', categories: ['vue3-essential', 'essential'], url: 'https://eslint.vuejs.org/rules/no-mutating-props.html' }, fixable: null, schema: [ { type: 'object', properties: { shallowOnly: { type: 'boolean' } }, additionalProperties: false } ], messages: { unexpectedMutation: 'Unexpected mutation of "{{key}}" prop.' } }, /** @param {RuleContext} context */ create(context) { const { shallowOnly } = parseOptions(context.options[0]) /** @type {Map} */ const propsMap = new Map() /** @type { { type: 'export' | 'mark' | 'definition', object: ObjectExpression } | { type: 'setup', object: CallExpression } | null } */ let vueObjectData = null /** * @param {ASTNode} node * @param {string} name */ function report(node, name) { context.report({ node, messageId: 'unexpectedMutation', data: { key: name } }) } /** * @param {MemberExpression|AssignmentProperty} node * @returns {string} */ function getPropertyNameText(node) { const name = utils.getStaticPropertyName(node) if (name) { return name } if (node.computed) { const expr = node.type === 'Property' ? node.key : node.property const str = context.getSourceCode().getText(expr) return `[${str}]` } return '?unknown?' } /** * @param {MemberExpression|Identifier} props * @param {string} name * @param {boolean} isRootProps */ function verifyMutating(props, name, isRootProps = false) { const invalid = utils.findMutating(props) if (invalid && isShallowOnlyInvalid(invalid, isRootProps)) { report(invalid.node, name) } } /** * @param {Pattern} param * @param {string[]} path * @returns {Generator<{ node: Identifier, path: string[] }>} */ function* iteratePatternProperties(param, path) { if (!param) { return } switch (param.type) { case 'Identifier': { yield { node: param, path } break } case 'RestElement': { yield* iteratePatternProperties(param.argument, path) break } case 'AssignmentPattern': { yield* iteratePatternProperties(param.left, path) break } case 'ObjectPattern': { for (const prop of param.properties) { if (prop.type === 'Property') { const name = getPropertyNameText(prop) yield* iteratePatternProperties(prop.value, [...path, name]) } else if (prop.type === 'RestElement') { yield* iteratePatternProperties(prop.argument, path) } } break } case 'ArrayPattern': { for (let index = 0; index < param.elements.length; index++) { const element = param.elements[index] if (element) yield* iteratePatternProperties(element, [...path, `${index}`]) } break } } } /** * @param {Identifier} prop * @param {string[]} path */ function verifyPropVariable(prop, path) { const variable = findVariable(utils.getScope(context, prop), prop) if (!variable) { return } for (const reference of variable.references) { if (!reference.isRead()) { continue } const id = reference.identifier const invalid = utils.findMutating(id) if (!invalid) { continue } let name if (!isShallowOnlyInvalid(invalid, path.length === 0)) { continue } if (path.length === 0) { if (invalid.pathNodes.length === 0) { continue } const mem = invalid.pathNodes[0] name = getPropertyNameText(mem) } else { if (invalid.pathNodes.length === 0 && invalid.kind !== 'call') { continue } name = path[0] } report(invalid.node, name) } } function* extractDefineVariableNames() { const globalScope = context.getSourceCode().scopeManager.globalScope if (globalScope) { for (const variable of globalScope.variables) { if (variable.defs.length > 0) { yield variable.name } } const moduleScope = globalScope.childScopes.find( (scope) => scope.type === 'module' ) for (const variable of (moduleScope && moduleScope.variables) || []) { if (variable.defs.length > 0) { yield variable.name } } } } /** * Is shallowOnly false or the prop reassigned * @param {Exclude, null>} invalid * @param {boolean} isRootProps * @return {boolean} */ function isShallowOnlyInvalid(invalid, isRootProps) { return ( !shallowOnly || (invalid.pathNodes.length === (isRootProps ? 1 : 0) && ['assignment', 'update'].includes(invalid.kind)) ) } return utils.compositingVisitors( {}, utils.defineScriptSetupVisitor(context, { onDefinePropsEnter(node, props) { const defineVariableNames = new Set(extractDefineVariableNames()) const propsInfo = { name: '', set: new Set( props .map((p) => p.propName) .filter( /** * @returns {propName is string} */ (propName) => utils.isDef(propName) && !GLOBALS_WHITE_LISTED.has(propName) && !defineVariableNames.has(propName) ) ) } propsMap.set(node, propsInfo) vueObjectData = { type: 'setup', object: node } let target = node if ( target.parent && target.parent.type === 'CallExpression' && target.parent.arguments[0] === target && target.parent.callee.type === 'Identifier' && target.parent.callee.name === 'withDefaults' ) { target = target.parent } if ( !target.parent || target.parent.type !== 'VariableDeclarator' || target.parent.init !== target ) { return } for (const { node: prop, path } of iteratePatternProperties( target.parent.id, [] )) { if (path.length === 0) { propsInfo.name = prop.name } else { propsInfo.set.add(prop.name) } verifyPropVariable(prop, path) } } }), utils.defineVueVisitor(context, { onVueObjectEnter(node) { propsMap.set(node, { set: new Set( utils .getComponentPropsFromOptions(node) .map((p) => p.propName) .filter(utils.isDef) ) }) }, onVueObjectExit(node, { type }) { if ( (!vueObjectData || (vueObjectData.type !== 'export' && vueObjectData.type !== 'setup')) && type !== 'instance' ) { vueObjectData = { type, object: node } } }, onSetupFunctionEnter(node) { const propsParam = node.params[0] if (!propsParam) { // no arguments return } if ( propsParam.type === 'RestElement' || propsParam.type === 'ArrayPattern' ) { // cannot check return } for (const { node: prop, path } of iteratePatternProperties( propsParam, [] )) { verifyPropVariable(prop, path) } }, /** @param {(Identifier | ThisExpression) & { parent: MemberExpression } } node */ 'MemberExpression > :matches(Identifier, ThisExpression)'( node, { node: vueNode } ) { if (!utils.isThis(node, context)) { return } const mem = node.parent if (mem.object !== node) { return } const name = utils.getStaticPropertyName(mem) if ( name && /** @type {PropsInfo} */ (propsMap.get(vueNode)).set.has(name) ) { verifyMutating(mem, name) } } }), utils.defineTemplateBodyVisitor(context, { /** @param {ThisExpression & { parent: MemberExpression } } node */ 'VExpressionContainer MemberExpression > ThisExpression'(node) { if (!vueObjectData) { return } const mem = node.parent if (mem.object !== node) { return } const name = utils.getStaticPropertyName(mem) if ( name && /** @type {PropsInfo} */ ( propsMap.get(vueObjectData.object) ).set.has(name) ) { verifyMutating(mem, name) } }, /** @param {Identifier } node */ 'VExpressionContainer Identifier'(node) { if (!vueObjectData) { return } if (!isVmReference(node)) { return } const propsInfo = /** @type {PropsInfo} */ ( propsMap.get(vueObjectData.object) ) const isRootProps = !!node.name && propsInfo.name === node.name const parent = node.parent const name = (isRootProps && parent.type === 'MemberExpression' && utils.getStaticPropertyName(parent)) || node.name if (name && (propsInfo.set.has(name) || isRootProps)) { verifyMutating(node, name, isRootProps) } }, /** @param {ESNode} node */ "VAttribute[directive=true]:matches([key.name.name='model'], [key.name.name='bind']) VExpressionContainer > *"( node ) { if (!vueObjectData) { return } let attr = node.parent while (attr && attr.type !== 'VAttribute') { attr = attr.parent } if ( attr && attr.directive && attr.key.name.name === 'bind' && !attr.key.modifiers.some((mod) => mod.name === 'sync') ) { return } const propsInfo = /** @type {PropsInfo} */ ( propsMap.get(vueObjectData.object) ) const nodes = utils.getMemberChaining(node) const first = nodes[0] let name if (isVmReference(first)) { if (first.name === propsInfo.name) { // props variable if (shallowOnly && nodes.length > 2) { return } name = (nodes[1] && getPropertyNameText(nodes[1])) || first.name } else { if (shallowOnly && nodes.length > 1) { return } name = first.name if (!name || !propsInfo.set.has(name)) { return } } } else if (first.type === 'ThisExpression') { if (shallowOnly && nodes.length > 2) { return } const mem = nodes[1] if (!mem) { return } name = utils.getStaticPropertyName(mem) if (!name || !propsInfo.set.has(name)) { return } } else { return } report(node, name) } }) ) } }