diff --git a/docs/rules/no-mutating-props.md b/docs/rules/no-mutating-props.md index 960ebe900..62c76680d 100644 --- a/docs/rules/no-mutating-props.md +++ b/docs/rules/no-mutating-props.md @@ -22,6 +22,8 @@ This rule reports mutation of component props. +``` + + ## :books: Further Reading diff --git a/lib/rules/no-mutating-props.js b/lib/rules/no-mutating-props.js index f79613d9c..61461010b 100644 --- a/lib/rules/no-mutating-props.js +++ b/lib/rules/no-mutating-props.js @@ -4,6 +4,10 @@ */ 'use strict' +/** + * @typedef {{name?: string, set: Set}} PropsInfo + */ + const utils = require('../utils') const { findVariable } = require('@eslint-community/eslint-utils') @@ -84,6 +88,19 @@ function isVmReference(node) { 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', @@ -94,12 +111,21 @@ module.exports = { }, fixable: null, // or "code" or "whitespace" schema: [ - // fill in your schema + { + type: 'object', + properties: { + shallowOnly: { + type: 'boolean' + } + }, + additionalProperties: false + } ] }, /** @param {RuleContext} context */ create(context) { - /** @type {Map>} */ + 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 @@ -138,10 +164,11 @@ module.exports = { /** * @param {MemberExpression|Identifier} props * @param {string} name + * @param {boolean} isRootProps */ - function verifyMutating(props, name) { + function verifyMutating(props, name, isRootProps = false) { const invalid = utils.findMutating(props) - if (invalid) { + if (invalid && isShallowOnlyInvalid(invalid, isRootProps)) { report(invalid.node, name) } } @@ -210,6 +237,9 @@ module.exports = { continue } let name + if (!isShallowOnlyInvalid(invalid, path.length === 0)) { + continue + } if (path.length === 0) { if (invalid.pathNodes.length === 0) { continue @@ -246,26 +276,43 @@ module.exports = { } } + /** + * 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 propsSet = 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, propsSet) + 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 @@ -294,22 +341,25 @@ module.exports = { target.parent.id, [] )) { + if (path.length === 0) { + propsInfo.name = prop.name + } else { + propsInfo.set.add(prop.name) + } verifyPropVariable(prop, path) - propsSet.add(prop.name) } } }), utils.defineVueVisitor(context, { onVueObjectEnter(node) { - propsMap.set( - node, - new Set( + propsMap.set(node, { + set: new Set( utils .getComponentPropsFromOptions(node) .map((p) => p.propName) .filter(utils.isDef) ) - ) + }) }, onVueObjectExit(node, { type }) { if ( @@ -359,7 +409,7 @@ module.exports = { const name = utils.getStaticPropertyName(mem) if ( name && - /** @type {Set} */ (propsMap.get(vueNode)).has(name) + /** @type {PropsInfo} */ (propsMap.get(vueNode)).set.has(name) ) { verifyMutating(mem, name) } @@ -378,9 +428,9 @@ module.exports = { const name = utils.getStaticPropertyName(mem) if ( name && - /** @type {Set} */ (propsMap.get(vueObjectData.object)).has( - name - ) + /** @type {PropsInfo} */ ( + propsMap.get(vueObjectData.object) + ).set.has(name) ) { verifyMutating(mem, name) } @@ -393,14 +443,18 @@ module.exports = { if (!isVmReference(node)) { return } - const name = node.name - if ( - name && - /** @type {Set} */ (propsMap.get(vueObjectData.object)).has( - name - ) - ) { - verifyMutating(node, name) + 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 */ @@ -423,28 +477,45 @@ module.exports = { return } + const propsInfo = /** @type {PropsInfo} */ ( + propsMap.get(vueObjectData.object) + ) + const nodes = utils.getMemberChaining(node) const first = nodes[0] let name if (isVmReference(first)) { - name = first.name + 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 } - if ( - name && - /** @type {Set} */ (propsMap.get(vueObjectData.object)).has( - name - ) - ) { - report(node, name) - } + report(node, name) } }) ) diff --git a/tests/lib/rules/no-mutating-props.js b/tests/lib/rules/no-mutating-props.js index 7338d9374..7c4884847 100644 --- a/tests/lib/rules/no-mutating-props.js +++ b/tests/lib/rules/no-mutating-props.js @@ -181,6 +181,37 @@ ruleTester.run('no-mutating-props', rule, { ` }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ shallowOnly: true }] + }, // setup { @@ -325,6 +356,43 @@ ruleTester.run('no-mutating-props', rule, { ` }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ shallowOnly: true }] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ shallowOnly: true }] + }, + { // script setup with shadow filename: 'test.vue', @@ -642,6 +710,63 @@ ruleTester.run('no-mutating-props', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ shallowOnly: true }], + errors: [ + { + message: 'Unexpected mutation of "prop1" prop.', + line: 4 + }, + { + message: 'Unexpected mutation of "prop2" prop.', + line: 5 + }, + { + message: 'Unexpected mutation of "prop5" prop.', + line: 8 + }, + { + message: 'Unexpected mutation of "prop6" prop.', + line: 9 + }, + { + message: 'Unexpected mutation of "prop10" prop.', + line: 13 + }, + { + message: 'Unexpected mutation of "prop10" prop.', + line: 22 + } + ] + }, // setup { @@ -820,6 +945,24 @@ ruleTester.run('no-mutating-props', rule, { errors: ['Unexpected mutation of "[a]" prop.'] }, + { + filename: 'test.vue', + code: ` + + + + `, + errors: [ + { + message: 'Unexpected mutation of "[foo]" prop.', + line: 7 + } + ] + }, { filename: 'test.vue', code: ` @@ -839,7 +982,7 @@ ruleTester.run('no-mutating-props', rule, { line: 3 }, { - message: 'Unexpected mutation of "props" prop.', + message: 'Unexpected mutation of "value" prop.', line: 4 } ] @@ -898,6 +1041,45 @@ ruleTester.run('no-mutating-props', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ shallowOnly: true }], + errors: [ + { + message: 'Unexpected mutation of "a" prop.', + line: 5 + }, + { + message: 'Unexpected mutation of "b" prop.', + line: 6 + }, + { + message: 'Unexpected mutation of "d" prop.', + line: 8 + }, + { + message: 'Unexpected mutation of "a" prop.', + line: 11 + } + ] + }, { // script setup with shadow @@ -911,13 +1093,15 @@ ruleTester.run('no-mutating-props', rule, { `, errors: [ @@ -932,6 +1116,50 @@ ruleTester.run('no-mutating-props', rule, { { message: 'Unexpected mutation of "Infinity" prop.', line: 6 + }, + { + message: 'Unexpected mutation of "obj" prop.', + line: 18 + } + ] + }, + + { + filename: 'test.vue', + code: ` + + + `, + options: [{ shallowOnly: true }], + errors: [ + { + message: 'Unexpected mutation of "a" prop.', + line: 3 + }, + { + message: 'Unexpected mutation of "a" prop.', + line: 4 + }, + { + message: 'Unexpected mutation of "a" prop.', + line: 7 + }, + { + message: 'Unexpected mutation of "a" prop.', + line: 15 } ] }