diff --git a/docs/rules/no-mutating-props.md b/docs/rules/no-mutating-props.md new file mode 100644 index 000000000..7c021a398 --- /dev/null +++ b/docs/rules/no-mutating-props.md @@ -0,0 +1,64 @@ +# disallow mutation of component props (vue/no-mutating-props) + +This rule reports mutation of component props. + +## Rule Details + +:-1: Examples of **incorrect** code for this rule: + +```html + + +``` + +:+1: Examples of **correct** code for this rule: + +```html + + +``` + +## :wrench: Options + +Nothing. + +## Related links + +- [Vue - Prop Mutation - deprecated](https://vuejs.org/v2/guide/migration.html#Prop-Mutation-deprecated) +- [Style guide - Implicit parent-child communication](https://vuejs.org/v2/style-guide/#Implicit-parent-child-communication-use-with-caution) diff --git a/lib/rules/no-mutating-props.js b/lib/rules/no-mutating-props.js new file mode 100644 index 000000000..726f5abca --- /dev/null +++ b/lib/rules/no-mutating-props.js @@ -0,0 +1,170 @@ +/** + * @fileoverview disallow mutation component props + * @author 2018 Armano + */ +'use strict' + +const utils = require('../utils') + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow mutation of component props', + category: undefined, + url: 'https://github.com/vuejs/eslint-plugin-vue/blob/v5.0.0-beta.5/docs/rules/no-mutating-props.md' + }, + fixable: null, // or "code" or "whitespace" + schema: [ + // fill in your schema + ] + }, + + create (context) { + let mutatedNodes = [] + let props = [] + let scope = { + parent: null, + nodes: [] + } + + function checkForMutations () { + if (mutatedNodes.length > 0) { + for (const prop of props) { + for (const node of mutatedNodes) { + if (prop === node.name) { + context.report({ + node: node.node, + message: 'Unexpected mutation of "{{key}}" prop.', + data: { + key: node.name + } + }) + } + } + } + } + mutatedNodes = [] + } + + function isInScope (name) { + return scope.nodes.some(node => node.name === name) + } + + function checkExpression (node, expression) { + if (expression[0] === 'this') { + mutatedNodes.push({ name: expression[1], node }) + } else { + const name = expression[0] + if (!isInScope(name)) { + mutatedNodes.push({ name, node }) + } + } + } + + function checkTemplateProperty (node) { + if (node.type === 'MemberExpression') { + checkExpression(node, utils.parseMemberExpression(node)) + } else if (node.type === 'Identifier') { + if (!isInScope(node.name)) { + mutatedNodes.push({ + name: node.name, + node + }) + } + } + } + + return Object.assign({}, + { + // this.xxx <=|+=|-=> + 'AssignmentExpression' (node) { + if (node.left.type !== 'MemberExpression') return + const expression = utils.parseMemberExpression(node.left) + if (expression[0] === 'this') { + mutatedNodes.push({ + name: expression[1], + node + }) + } + }, + // this.xxx <++|--> + 'UpdateExpression > MemberExpression' (node) { + const expression = utils.parseMemberExpression(node) + if (expression[0] === 'this') { + mutatedNodes.push({ + name: expression[1], + node + }) + } + }, + // this.xxx.func() + 'CallExpression' (node) { + const expression = utils.parseMemberOrCallExpression(node) + const code = expression.join('.').replace(/\.\[/g, '[') + const MUTATION_REGEX = /(this.)((?!(concat|slice|map|filter)\().)[^\)]*((push|pop|shift|unshift|reverse|splice|sort|copyWithin|fill)\()/g + + if (MUTATION_REGEX.test(code)) { + if (expression[0] === 'this') { + mutatedNodes.push({ + name: expression[1], + node + }) + } + } + } + }, + utils.executeOnVue(context, (obj) => { + props = utils.getComponentProps(obj) + .filter(cp => cp.key) + .map(cp => utils.getStaticPropertyName(cp.key)) + checkForMutations() + }), + + utils.defineTemplateBodyVisitor(context, { + VElement (node) { + scope = { + parent: scope, + nodes: scope.nodes.slice() // make copy + } + + if (node.variables) { + for (const variable of node.variables) { + scope.nodes.push(variable.id) + } + } + }, + 'VElement:exit' () { + scope = scope.parent + }, + 'VExpressionContainer AssignmentExpression' (node) { + checkTemplateProperty(node.left) + }, + // this.xxx <++|--> + 'VExpressionContainer UpdateExpression' (node) { + checkTemplateProperty(node.argument) + }, + // this.xxx.func() + 'VExpressionContainer CallExpression' (node) { + const expression = utils.parseMemberOrCallExpression(node) + const code = expression.join('.').replace(/\.\[/g, '[') + const MUTATION_REGEX = /(this.)?((?!(concat|slice|map|filter)\().)[^\)]*((push|pop|shift|unshift|reverse|splice|sort|copyWithin|fill)\()/g + + if (MUTATION_REGEX.test(code)) { + checkExpression(node, expression) + } + }, + "VAttribute[directive=true][key.name='model'] VExpressionContainer" (node) { + checkTemplateProperty(node.expression) + }, + "VElement[name='template']:exit" () { + checkForMutations() + } + }) + ) + } +} diff --git a/lib/rules/no-side-effects-in-computed-properties.js b/lib/rules/no-side-effects-in-computed-properties.js index 627e9ab4d..d68ced413 100644 --- a/lib/rules/no-side-effects-in-computed-properties.js +++ b/lib/rules/no-side-effects-in-computed-properties.js @@ -43,6 +43,9 @@ module.exports = { // this.xxx.func() 'CallExpression' (node) { const code = utils.parseMemberOrCallExpression(node) + .join('.') + .replace(/\.\[/g, '[') + const MUTATION_REGEX = /(this.)((?!(concat|slice|map|filter)\().)[^\)]*((push|pop|shift|unshift|reverse|splice|sort|copyWithin|fill)\()/g if (MUTATION_REGEX.test(code)) { @@ -53,8 +56,8 @@ module.exports = { utils.executeOnVue(context, (obj) => { const computedProperties = utils.getComputedProperties(obj) - computedProperties.forEach(cp => { - forbiddenNodes.forEach(node => { + for (const cp of computedProperties) { + for (const node of forbiddenNodes) { if ( cp.value && node.loc.start.line >= cp.value.loc.start.line && @@ -66,8 +69,8 @@ module.exports = { data: { key: cp.key } }) } - }) - }) + } + } }) ) } diff --git a/lib/utils/index.js b/lib/utils/index.js index cf58a0fce..665aac88f 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -782,7 +782,7 @@ module.exports = { parsedCallee.push('this') } - return parsedCallee.reverse().join('.').replace(/\.\[/g, '[') + return parsedCallee.reverse() }, /** diff --git a/tests/lib/rules/no-mutating-props.js b/tests/lib/rules/no-mutating-props.js new file mode 100644 index 000000000..3b141d6c3 --- /dev/null +++ b/tests/lib/rules/no-mutating-props.js @@ -0,0 +1,333 @@ +/** + * @fileoverview disallow mutation of component props + * @author 2018 Armano + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/no-mutating-props') +const RuleTester = require('eslint').RuleTester + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: 'vue-eslint-parser', + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module' + } +}) + +ruleTester.run('no-mutating-props', rule, { + + valid: [ + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + } + ], + + invalid: [ + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: 'Unexpected mutation of "prop1" prop.', + line: 4 + }, + { + message: 'Unexpected mutation of "prop2" prop.', + line: 5 + }, + { + message: 'Unexpected mutation of "prop3" prop.', + line: 6 + }, + { + message: 'Unexpected mutation of "prop4" prop.', + line: 7 + }, + { + message: 'Unexpected mutation of "prop5" prop.', + line: 8 + }, + { + message: 'Unexpected mutation of "prop6" prop.', + line: 9 + }, + { + message: 'Unexpected mutation of "prop7" prop.', + line: 10 + }, + { + message: 'Unexpected mutation of "prop8" prop.', + line: 11 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: 'Unexpected mutation of "prop1" prop.', + line: 4 + }, + { + message: 'Unexpected mutation of "prop2" prop.', + line: 5 + }, + { + message: 'Unexpected mutation of "prop3" prop.', + line: 6 + }, + { + message: 'Unexpected mutation of "prop4" prop.', + line: 7 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Unexpected mutation of "items" prop.', + line: 16 + }, + { + message: 'Unexpected mutation of "todo" prop.', + line: 17 + }, + { + message: 'Unexpected mutation of "items" prop.', + line: 18 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: 'Unexpected mutation of "prop" prop.', + line: 8 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: 'Unexpected mutation of "prop" prop.', + line: 5 + }, + { + message: 'Unexpected mutation of "prop" prop.', + line: 7 + } + ] + } + ] +}) diff --git a/tests/lib/utils/index.js b/tests/lib/utils/index.js index 0b8c07e2d..e17536dcc 100644 --- a/tests/lib/utils/index.js +++ b/tests/lib/utils/index.js @@ -199,13 +199,13 @@ describe('parseMemberOrCallExpression', () => { it('should parse CallExpression', () => { node = parse(`const test = this.lorem['ipsum'].map(d => d.id).filter((a, b) => a > b).reduce((acc, d) => acc + d, 0)`) const parsed = utils.parseMemberOrCallExpression(node) - assert.equal(parsed, 'this.lorem[].map().filter().reduce()') + assert.deepEqual(parsed, ['this', 'lorem', '[]', 'map()', 'filter()', 'reduce()']) }) it('should parse MemberExpression', () => { node = parse(`const test = this.lorem['ipsum'][0].map(d => d.id).dolor.reduce((acc, d) => acc + d, 0).sit`) const parsed = utils.parseMemberOrCallExpression(node) - assert.equal(parsed, 'this.lorem[][].map().dolor.reduce().sit') + assert.deepEqual(parsed, ['this', 'lorem', '[]', '[]', 'map()', 'dolor', 'reduce()', 'sit']) }) })