From 7b640fe468689f802393569bed35e847924b7710 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 09:47:21 +0200 Subject: [PATCH 01/32] initial version --- lib/rules/no-shadow-native-events.js | 371 +++++++++++++++++++++ lib/utils/dom-events.json | 85 +++++ tests/lib/rules/no-shadow-native-events.js | 112 +++++++ 3 files changed, 568 insertions(+) create mode 100644 lib/rules/no-shadow-native-events.js create mode 100644 lib/utils/dom-events.json create mode 100644 tests/lib/rules/no-shadow-native-events.js diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js new file mode 100644 index 000000000..3fc7b7411 --- /dev/null +++ b/lib/rules/no-shadow-native-events.js @@ -0,0 +1,371 @@ +/** + * @author Jonathan Carle + * See LICENSE file in root directory for full license. + */ +'use strict' + +const utils = require('../utils') +const domEvents = require('../utils/dom-events.json') +const { capitalize } = require('../utils/casing.js') +const { + findVariable, + isOpeningBraceToken, + isClosingBraceToken, + isOpeningBracketToken +} = require('@eslint-community/eslint-utils') +/** + * @typedef {import('../utils').ComponentEmit} ComponentEmit + * @typedef {import('../utils').ComponentProp} ComponentProp + * @typedef {import('../utils').VueObjectData} VueObjectData + */ + +/** + * Get the name param node from the given CallExpression + * @param {CallExpression} node CallExpression + * @returns { NameWithLoc | null } + */ +function getNameParamNode(node) { + const nameLiteralNode = node.arguments[0] + if (nameLiteralNode && utils.isStringLiteral(nameLiteralNode)) { + const name = utils.getStringLiteralValue(nameLiteralNode) + if (name != null) { + return { name, loc: nameLiteralNode.loc, range: nameLiteralNode.range } + } + } + + // cannot check + return null +} + +/** + * Check if the given name matches defineEmitsNode variable name + * @param {string} name + * @param {CallExpression | undefined} defineEmitsNode + * @returns {boolean} + */ +function isEmitVariableName(name, defineEmitsNode) { + const node = defineEmitsNode?.parent + + if (node?.type === 'VariableDeclarator' && node.id.type === 'Identifier') { + return name === node.id.name + } + + return false +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'disallow the use of event names that collide with native web event names', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/no-shadow-native-events.html' + }, + fixable: null, + hasSuggestions: false, + schema: [ + { + type: 'object', + properties: { + allowProps: { + type: 'boolean' + } + }, + additionalProperties: false + } + ], + messages: { + violation: + 'Use a different emit name to avoid shadowing native events: {{ name }}.' + } + }, + /** @param {RuleContext} context */ + create(context) { + const options = context.options[0] || {} + const allowProps = !!options.allowProps + /** @type {Map, emitReferenceIds: Set }>} */ + const setupContexts = new Map() + + /** + * @typedef {object} VueTemplateDefineData + * @property {'export' | 'mark' | 'definition' | 'setup'} type + * @property {ObjectExpression | Program} define + * @property {CallExpression} [defineEmits] + */ + /** @type {VueTemplateDefineData | null} */ + let vueTemplateDefineData = null + + // TODO: needed? + const programNode = context.getSourceCode().ast + if (utils.isScriptSetup(context)) { + // init + vueTemplateDefineData = { + type: 'setup', + define: programNode + } + } + + /** + * @param {NameWithLoc} nameWithLoc + */ + function verifyEmit(nameWithLoc) { + const name = nameWithLoc.name.toLowerCase() + if (!domEvents.includes(name)) { + return + } + context.report({ + loc: nameWithLoc.loc, + messageId: 'violation', + data: { + name + } + }) + } + + const callVisitor = { + /** + * @param {CallExpression} node + * @param {VueObjectData} [info] + */ + CallExpression(node, info) { + const callee = utils.skipChainExpression(node.callee) + const nameWithLoc = getNameParamNode(node) + if (!nameWithLoc) { + // cannot check + return + } + const vueDefineNode = info ? info.node : programNode + + let emit + if (callee.type === 'MemberExpression') { + const name = utils.getStaticPropertyName(callee) + if (name === 'emit' || name === '$emit') { + emit = { name, member: callee } + } + } + + // verify setup context + const setupContext = setupContexts.get(vueDefineNode) + if (setupContext) { + const { contextReferenceIds, emitReferenceIds } = setupContext + if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) { + // verify setup(props,{emit}) {emit()} + verifyEmit(nameWithLoc) + } else if (emit && emit.name === 'emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if ( + memObject.type === 'Identifier' && + contextReferenceIds.has(memObject) + ) { + // verify setup(props,context) {context.emit()} + verifyEmit(nameWithLoc) + } + } + } + + // verify $emit + if (emit && emit.name === '$emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if (utils.isThis(memObject, context)) { + // verify this.$emit() + verifyEmit(nameWithLoc) + } + } + } + } + /** + * @param {ComponentEmit[]} emits + */ + const verifyEmitDeclaration = (emits) => { + for (const { node, emitName } of emits) { + if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { + continue + } + + context.report({ + messageId: 'violation', + data: { name: emitName }, + loc: node.loc + }) + } + } + + return utils.compositingVisitors( + utils.defineTemplateBodyVisitor( + context, + { + /** @param { CallExpression } node */ + CallExpression(node) { + const callee = utils.skipChainExpression(node.callee) + const nameWithLoc = getNameParamNode(node) + if (!nameWithLoc) { + // cannot check + return + } + + // e.g. $emit() / emit() in template + if ( + callee.type === 'Identifier' && + (callee.name === '$emit' || + (vueTemplateDefineData?.defineEmits && + isEmitVariableName( + callee.name, + vueTemplateDefineData.defineEmits + ))) + ) { + verifyEmit(nameWithLoc) + } + } + }, + utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefineEmitsEnter: (node, emits) => { + verifyEmitDeclaration(emits) + + // TODO: needed? + if ( + vueTemplateDefineData && + vueTemplateDefineData.type === 'setup' + ) { + vueTemplateDefineData.defineEmits = node + } + + if ( + !node.parent || + node.parent.type !== 'VariableDeclarator' || + node.parent.init !== node + ) { + return + } + + const emitParam = node.parent.id + const variable = + emitParam.type === 'Identifier' + ? findVariable(utils.getScope(context, emitParam), emitParam) + : null + if (!variable) { + return + } + /** @type {Set} */ + const emitReferenceIds = new Set() + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + emitReferenceIds.add(reference.identifier) + } + setupContexts.set(programNode, { + contextReferenceIds: new Set(), + emitReferenceIds + }) + }, + onDefinePropsEnter: (node, props) => { + if (allowProps) { + // TODO: verify props + } + }, + ...callVisitor + }), + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node, { node: vueNode }) { + const contextParam = node.params[1] + if (!contextParam) { + // no arguments + return + } + if (contextParam.type === 'RestElement') { + // cannot check + return + } + if (contextParam.type === 'ArrayPattern') { + // cannot check + return + } + /** @type {Set} */ + const contextReferenceIds = new Set() + /** @type {Set} */ + const emitReferenceIds = new Set() + if (contextParam.type === 'ObjectPattern') { + const emitProperty = utils.findAssignmentProperty( + contextParam, + 'emit' + ) + if (!emitProperty) { + return + } + const emitParam = emitProperty.value + // `setup(props, {emit})` + const variable = + emitParam.type === 'Identifier' + ? findVariable( + utils.getScope(context, emitParam), + emitParam + ) + : null + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + emitReferenceIds.add(reference.identifier) + } + } else if (contextParam.type === 'Identifier') { + // `setup(props, context)` + const variable = findVariable( + utils.getScope(context, contextParam), + contextParam + ) + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + contextReferenceIds.add(reference.identifier) + } + } + setupContexts.set(vueNode, { + contextReferenceIds, + emitReferenceIds + }) + }, + onVueObjectEnter(node) { + const emits = utils.getComponentEmitsFromOptions(node) + verifyEmitDeclaration(emits) + + if (allowProps) { + // TODO: verify props + // utils.getComponentPropsFromOptions(node) + } + }, + onVueObjectExit(node, { type }) { + if ( + (!vueTemplateDefineData || + (vueTemplateDefineData.type !== 'export' && + vueTemplateDefineData.type !== 'setup')) && + (type === 'mark' || type === 'export' || type === 'definition') + ) { + vueTemplateDefineData = { + type, + define: node + } + } + setupContexts.delete(node) + }, + ...callVisitor + }) + ) + ) + ) + } +} diff --git a/lib/utils/dom-events.json b/lib/utils/dom-events.json new file mode 100644 index 000000000..c3ae47089 --- /dev/null +++ b/lib/utils/dom-events.json @@ -0,0 +1,85 @@ +[ + "copy", + "cut", + "paste", + "compositionend", + "compositionstart", + "compositionupdate", + "drag", + "dragend", + "dragenter", + "dragexit", + "dragleave", + "dragover", + "dragstart", + "drop", + "focus", + "focusin", + "focusout", + "blur", + "change", + "beforeinput", + "input", + "reset", + "submit", + "invalid", + "load", + "error", + "keydown", + "keypress", + "keyup", + "auxclick", + "click", + "contextmenu", + "dblclick", + "mousedown", + "mouseenter", + "mouseleave", + "mousemove", + "mouseout", + "mouseover", + "mouseup", + "abort", + "canplay", + "canplaythrough", + "durationchange", + "emptied", + "encrypted", + "ended", + "loadeddata", + "loadedmetadata", + "loadstart", + "pause", + "play", + "playing", + "progress", + "ratechange", + "seeked", + "seeking", + "stalled", + "suspend", + "timeupdate", + "volumechange", + "waiting", + "select", + "scroll", + "scrollend", + "touchcancel", + "touchend", + "touchmove", + "touchstart", + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "pointerenter", + "pointerleave", + "pointerover", + "pointerout", + "wheel", + "animationstart", + "animationend", + "animationiteration", + "transitionend", + "transitionstart" +] diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js new file mode 100644 index 000000000..76b442b14 --- /dev/null +++ b/tests/lib/rules/no-shadow-native-events.js @@ -0,0 +1,112 @@ +/** + * @author Jonathan Carle + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('../../eslint-compat').RuleTester +const rule = require('../../../lib/rules/no-shadow-native-events') + +const tester = new RuleTester({ + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('no-shadow-native-events', rule, { + valid: [], + invalid: [ + { + filename: 'test.vue', + code: ` + `, + languageOptions: { + parserOptions: { + parser: require.resolve('@typescript-eslint/parser') + } + }, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test2.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + }, + { + messageId: 'violation' + } + ] + }, + { + filename: 'test3.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test4.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test5.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test5.vue', + code: ` + + `, + + errors: [ + { + messageId: 'violation' + } + ] + } + ] +}) From 8ad0f4b103fe4022678f49a0a05c461b0df97b1a Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:00:49 +0200 Subject: [PATCH 02/32] added invalid tests --- lib/rules/no-shadow-native-events.js | 7 +- tests/lib/rules/no-shadow-native-events.js | 484 +++++++++++++++++++-- 2 files changed, 455 insertions(+), 36 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 3fc7b7411..42fef0492 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -90,6 +90,9 @@ module.exports = { /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() + /** @type {string[]} */ + const definedAndReportedEmits = [] + /** * @typedef {object} VueTemplateDefineData * @property {'export' | 'mark' | 'definition' | 'setup'} type @@ -99,7 +102,6 @@ module.exports = { /** @type {VueTemplateDefineData | null} */ let vueTemplateDefineData = null - // TODO: needed? const programNode = context.getSourceCode().ast if (utils.isScriptSetup(context)) { // init @@ -114,7 +116,7 @@ module.exports = { */ function verifyEmit(nameWithLoc) { const name = nameWithLoc.name.toLowerCase() - if (!domEvents.includes(name)) { + if (!domEvents.includes(name) || definedAndReportedEmits.includes(name)) { return } context.report({ @@ -186,6 +188,7 @@ module.exports = { continue } + definedAndReportedEmits.push(emitName) context.report({ messageId: 'violation', data: { name: emitName }, diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js index 76b442b14..a24b15f33 100644 --- a/tests/lib/rules/no-shadow-native-events.js +++ b/tests/lib/rules/no-shadow-native-events.js @@ -6,6 +6,9 @@ const RuleTester = require('../../eslint-compat').RuleTester const rule = require('../../../lib/rules/no-shadow-native-events') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ languageOptions: { @@ -21,90 +24,503 @@ tester.run('no-shadow-native-events', rule, { { filename: 'test.vue', code: ` - `, - languageOptions: { - parserOptions: { - parser: require.resolve('@typescript-eslint/parser') + + + `, + errors: [ + { + line: 3, + column: 28, + messageId: 'violation', + endLine: 3, + endColumn: 35 } - }, + ] + }, + { + filename: 'test.vue', + code: ` + + + `, errors: [ { - messageId: 'violation' + line: 7, + column: 17, + messageId: 'violation', + endLine: 7, + endColumn: 24 } ] }, { - filename: 'test2.vue', + filename: 'test.vue', code: ` - `, + + + `, errors: [ { - messageId: 'violation' - }, + line: 7, + column: 17, + messageId: 'violation', + endLine: 7, + endColumn: 26 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ { - messageId: 'violation' + line: 6, + column: 24, + messageId: 'violation', + endLine: 6, + endColumn: 31 } ] }, { - filename: 'test3.vue', + filename: 'test.vue', code: ` `, + export default { + emits: ['welcome'], + methods: { + onClick() { + const vm = this + vm.$emit('click') + } + } + } + + `, errors: [ { - messageId: 'violation' + line: 8, + column: 22, + messageId: 'violation', + endLine: 8, + endColumn: 29 } ] }, { - filename: 'test4.vue', + filename: 'test.vue', code: ` + `, + errors: [ + { + line: 5, + column: 24, + messageId: 'violation', + endLine: 5, + endColumn: 31 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 5, + column: 16, + messageId: 'violation', + endLine: 5, + endColumn: 23 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 5, + column: 16, + messageId: 'violation', + endLine: 5, + endColumn: 23 }, - }; - `, + { + line: 6, + column: 16, + messageId: 'violation', + endLine: 6, + endColumn: 25 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, errors: [ { - messageId: 'violation' + messageId: 'violation', + line: 7, + column: 25, + endLine: 7, + endColumn: 32 + }, + { + messageId: 'violation', + line: 8, + column: 28, + endLine: 8, + endColumn: 37 } ] }, { - filename: 'test5.vue', + filename: 'test.vue', code: ` `, + + `, errors: [ { - messageId: 'violation' + messageId: 'violation', + line: 5, + column: 21, + endLine: 5, + endColumn: 28 + }, + { + messageId: 'violation', + line: 6, + column: 24, + endLine: 6, + endColumn: 33 } ] }, + // `, - errors: [ { - messageId: 'violation' + messageId: 'violation', + line: 6, + column: 20, + endLine: 6, + endColumn: 27 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 7, + column: 9, + endLine: 7, + endColumn: 27 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 6, + column: 19, + endLine: 6, + endColumn: 39 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + messageId: 'violation', + line: 3, + column: 28, + endLine: 3, + endColumn: 35 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 32, + endLine: 3, + endColumn: 52 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'violation', + line: 3, + column: 33, + endLine: 3, + endColumn: 40 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 33, + endLine: 3, + endColumn: 42 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 21, + endLine: 3, + endColumn: 30 + }, + { + messageId: 'violation', + line: 6, + column: 12, + endLine: 6, + endColumn: 21 + } + ] + }, + { + code: ` + `, + errors: [ + { + messageId: 'violation', + line: 4, + column: 32, + endLine: 4, + endColumn: 37 + }, + { + messageId: 'violation', + line: 4, + column: 32, + endLine: 4, + endColumn: 37 + } + ], + ...getTypeScriptFixtureTestOptions() + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 27, + endLine: 3, + endColumn: 36 + }, + { + messageId: 'violation', + line: 6, + column: 32, + endLine: 6, + endColumn: 52 } ] } From fe3615d4e0ddc53c9e486e1f0bb1660f9b67ae8b Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:04:07 +0200 Subject: [PATCH 03/32] added valid tests cases --- tests/lib/rules/no-shadow-native-events.js | 641 ++++++++++++++++++++- 1 file changed, 640 insertions(+), 1 deletion(-) diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js index a24b15f33..4af73fbb0 100644 --- a/tests/lib/rules/no-shadow-native-events.js +++ b/tests/lib/rules/no-shadow-native-events.js @@ -19,7 +19,646 @@ const tester = new RuleTester({ }) tester.run('no-shadow-native-events', rule, { - valid: [], + valid: [ + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + // quoted + { + filename: 'test.vue', + code: ` + + + ` + }, + // unknown + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + // allowProps + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + + // + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + + // unknown emits definition + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + + // unknown props definition + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + } + ], invalid: [ { filename: 'test.vue', From 892f277f5034f68f5e44699a331262293a3d5f5c Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:51:43 +0200 Subject: [PATCH 04/32] clean up --- lib/rules/no-shadow-native-events.js | 34 +++------------------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 42fef0492..364c35eeb 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -6,13 +6,7 @@ const utils = require('../utils') const domEvents = require('../utils/dom-events.json') -const { capitalize } = require('../utils/casing.js') -const { - findVariable, - isOpeningBraceToken, - isClosingBraceToken, - isOpeningBracketToken -} = require('@eslint-community/eslint-utils') +const { findVariable } = require('@eslint-community/eslint-utils') /** * @typedef {import('../utils').ComponentEmit} ComponentEmit * @typedef {import('../utils').ComponentProp} ComponentProp @@ -67,26 +61,14 @@ module.exports = { }, fixable: null, hasSuggestions: false, - schema: [ - { - type: 'object', - properties: { - allowProps: { - type: 'boolean' - } - }, - additionalProperties: false - } - ], + schema: [], messages: { violation: - 'Use a different emit name to avoid shadowing native events: {{ name }}.' + 'Use a different emit name to avoid shadowing the native event with name "{{ name }}". Consider an emit name which communicates the users intent, if applicable.' } }, /** @param {RuleContext} context */ create(context) { - const options = context.options[0] || {} - const allowProps = !!options.allowProps /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() @@ -267,11 +249,6 @@ module.exports = { emitReferenceIds }) }, - onDefinePropsEnter: (node, props) => { - if (allowProps) { - // TODO: verify props - } - }, ...callVisitor }), utils.defineVueVisitor(context, { @@ -345,11 +322,6 @@ module.exports = { onVueObjectEnter(node) { const emits = utils.getComponentEmitsFromOptions(node) verifyEmitDeclaration(emits) - - if (allowProps) { - // TODO: verify props - // utils.getComponentPropsFromOptions(node) - } }, onVueObjectExit(node, { type }) { if ( From 391a4bb83d21c7c96ad91e26faebfa0dc10a332d Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:51:47 +0200 Subject: [PATCH 05/32] add docs --- docs/rules/no-shadow-native-events.md | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/rules/no-shadow-native-events.md diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md new file mode 100644 index 000000000..1e00637f9 --- /dev/null +++ b/docs/rules/no-shadow-native-events.md @@ -0,0 +1,50 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/no-shadow-native-events +description: disallow `emits` which would shadow native html events +--- +# vue/no-shadow-native-events + +> disallow `emits` which would shadow native HTML events + +- :exclamation: _**This rule has not been released yet.**_ + +## :book: Rule Details + +This rule reports emits that shadow native HTML events. (The `emits` option is a new in Vue.js 3.0.0+) + +Using native event names for emits can lead to incorrect assumptions about an emit and cause confusion. This is caused by Vue emits behaving differently from native events. E.g. : +- The payload of an emit can be chosen arbitrarily +- Vue emits do not bubble, while most native events do +- [Event modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers) only work on HTML events or when the original event is re-emitted as emit payload. +- When the native event is remitted the `event.target` might not match the actual event-listeners location. + +The rule is mostly aimed at developers of component libraries. + + + +```vue + +``` + + + +## :wrench: Options + +Nothing. + +## :couple: Related Rules + +- [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) +- [vue/require-explicit-emits](./require-explicit-emits.md) + +## :books: Further Reading + +- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) +- [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) From 654332ad2d9364919360c174588b68c8cb16f5b0 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:52:28 +0200 Subject: [PATCH 06/32] update related docs --- docs/rules/require-explicit-emits.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index d04dec8b3..f00022b83 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -114,10 +114,11 @@ export default { - [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) - [vue/require-explicit-slots](./require-explicit-slots.md) +- [vue/no-shadow-native](./no-shadow-native.md) ## :books: Further Reading -- [Guide - Custom Events / Defining Custom Events](https://v3.vuejs.org/guide/component-custom-events.html#defining-custom-events) +- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) ## :rocket: Version From 300c138d2e51865172633c5dbf6e347628769b1e Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:52:56 +0200 Subject: [PATCH 07/32] add missing test fixture --- tests/fixtures/typescript/src/test01.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/typescript/src/test01.ts b/tests/fixtures/typescript/src/test01.ts index d14550843..169dfb1c7 100644 --- a/tests/fixtures/typescript/src/test01.ts +++ b/tests/fixtures/typescript/src/test01.ts @@ -7,6 +7,10 @@ export type Emits1 = { (e: 'foo' | 'bar', payload: string): void (e: 'baz', payload: number): void } +export type Emits2 = { + (e: 'click' | 'bar', payload: string): void + (e: 'keydown', payload: number): void +} export type Props2 = { a: string b?: number From c3e68caf853cd2f57a6d5b25a67df286bb692f33 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:53:39 +0200 Subject: [PATCH 08/32] fix link --- docs/rules/require-explicit-emits.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index f00022b83..38ac41448 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -114,7 +114,7 @@ export default { - [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) - [vue/require-explicit-slots](./require-explicit-slots.md) -- [vue/no-shadow-native](./no-shadow-native.md) +- [vue/no-shadow-native-events](./no-shadow-native-events.md) ## :books: Further Reading From 218a57786b4800ebc647eb6eeec97f6a90a55a43 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 16:08:34 +0200 Subject: [PATCH 09/32] fix missing typedef for NameWithLoc --- lib/rules/no-shadow-native-events.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 364c35eeb..47d893e04 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -11,6 +11,7 @@ const { findVariable } = require('@eslint-community/eslint-utils') * @typedef {import('../utils').ComponentEmit} ComponentEmit * @typedef {import('../utils').ComponentProp} ComponentProp * @typedef {import('../utils').VueObjectData} VueObjectData + * @typedef {import('./require-explicit-emits.js').NameWithLoc} NameWithLoc */ /** From 4e55123ba60b3148e7da85ca7cb3810de8892631 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:06:42 +0200 Subject: [PATCH 10/32] lint issue: remove todo --- lib/rules/no-shadow-native-events.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 47d893e04..4d6dad71c 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -211,8 +211,6 @@ module.exports = { utils.defineScriptSetupVisitor(context, { onDefineEmitsEnter: (node, emits) => { verifyEmitDeclaration(emits) - - // TODO: needed? if ( vueTemplateDefineData && vueTemplateDefineData.type === 'setup' From ddfb9044753fe1e640fd66e0431da6d0b70740cb Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:09:50 +0200 Subject: [PATCH 11/32] fix lint issue: "space inside link text" --- docs/rules/no-shadow-native-events.md | 3 ++- docs/rules/require-explicit-emits.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md index 1e00637f9..843fa8aec 100644 --- a/docs/rules/no-shadow-native-events.md +++ b/docs/rules/no-shadow-native-events.md @@ -15,6 +15,7 @@ description: disallow `emits` which would shadow native html events This rule reports emits that shadow native HTML events. (The `emits` option is a new in Vue.js 3.0.0+) Using native event names for emits can lead to incorrect assumptions about an emit and cause confusion. This is caused by Vue emits behaving differently from native events. E.g. : + - The payload of an emit can be chosen arbitrarily - Vue emits do not bubble, while most native events do - [Event modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers) only work on HTML events or when the original event is re-emitted as emit payload. @@ -46,5 +47,5 @@ Nothing. ## :books: Further Reading -- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) +- [Components In-Depth - Events / Component Events](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index 38ac41448..507f47398 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -118,7 +118,7 @@ export default { ## :books: Further Reading -- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) +- [Components In-Depth - Events / Component Events](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) ## :rocket: Version From 81ddf6ab85908bae65f34db37327e45bf10eb09c Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:13:22 +0200 Subject: [PATCH 12/32] remove unused options --- tests/lib/rules/no-shadow-native-events.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js index 4af73fbb0..63ccca7bc 100644 --- a/tests/lib/rules/no-shadow-native-events.js +++ b/tests/lib/rules/no-shadow-native-events.js @@ -395,8 +395,7 @@ tester.run('no-shadow-native-events', rule, { } } - `, - options: [{ allowProps: true }] + ` }, // - `, - options: [{ allowProps: true }] + ` }, { filename: 'test.vue', @@ -587,8 +585,7 @@ tester.run('no-shadow-native-events', rule, { - `, - options: [{ allowProps: true }] + ` }, { filename: 'test.vue', @@ -601,8 +598,7 @@ tester.run('no-shadow-native-events', rule, { props: [foo], } - `, - options: [{ allowProps: true }] + ` }, { filename: 'test.vue', @@ -615,8 +611,7 @@ tester.run('no-shadow-native-events', rule, { props: [...foo], } - `, - options: [{ allowProps: true }] + ` }, { // new syntax in Vue 3.3 From 7d47fac739606a8392590ba51b434b5a20eb20a2 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:19:31 +0200 Subject: [PATCH 13/32] replace array with Set --- lib/rules/no-shadow-native-events.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 4d6dad71c..8a3aa3929 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -73,8 +73,8 @@ module.exports = { /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() - /** @type {string[]} */ - const definedAndReportedEmits = [] + /** @type {Set} */ + const definedAndReportedEmits = new Set() /** * @typedef {object} VueTemplateDefineData @@ -99,7 +99,7 @@ module.exports = { */ function verifyEmit(nameWithLoc) { const name = nameWithLoc.name.toLowerCase() - if (!domEvents.includes(name) || definedAndReportedEmits.includes(name)) { + if (!domEvents.includes(name) || definedAndReportedEmits.has(name)) { return } context.report({ @@ -171,7 +171,7 @@ module.exports = { continue } - definedAndReportedEmits.push(emitName) + definedAndReportedEmits.add(emitName) context.report({ messageId: 'violation', data: { name: emitName }, From 82123a36bf289c504dd43cf7848294701b7a1450 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:33:28 +0200 Subject: [PATCH 14/32] add explanatory comments --- lib/rules/no-shadow-native-events.js | 44 +++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 8a3aa3929..b13be9c6f 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -60,8 +60,6 @@ module.exports = { categories: undefined, url: 'https://eslint.vuejs.org/rules/no-shadow-native-events.html' }, - fixable: null, - hasSuggestions: false, schema: [], messages: { violation: @@ -73,7 +71,10 @@ module.exports = { /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() - /** @type {Set} */ + /** + * Tracks violating emit definitions, so that calls of this emit are not reported additionally. + * @type {Set} + * */ const definedAndReportedEmits = new Set() /** @@ -95,6 +96,7 @@ module.exports = { } /** + * Verify if an emit call violates the rule of not using a native dom event name. * @param {NameWithLoc} nameWithLoc */ function verifyEmit(nameWithLoc) { @@ -111,6 +113,25 @@ module.exports = { }) } + /** + * Verify if an emit declaration violates the rule of not using a native dom event name. + * @param {ComponentEmit[]} emits + */ + const verifyEmitDeclaration = (emits) => { + for (const { node, emitName } of emits) { + if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { + continue + } + + definedAndReportedEmits.add(emitName) + context.report({ + messageId: 'violation', + data: { name: emitName }, + loc: node.loc + }) + } + } + const callVisitor = { /** * @param {CallExpression} node @@ -162,23 +183,6 @@ module.exports = { } } } - /** - * @param {ComponentEmit[]} emits - */ - const verifyEmitDeclaration = (emits) => { - for (const { node, emitName } of emits) { - if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { - continue - } - - definedAndReportedEmits.add(emitName) - context.report({ - messageId: 'violation', - data: { name: emitName }, - loc: node.loc - }) - } - } return utils.compositingVisitors( utils.defineTemplateBodyVisitor( From c72133f25986b251c52fb904fb0824810e054293 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 09:47:21 +0200 Subject: [PATCH 15/32] initial version --- lib/rules/no-shadow-native-events.js | 371 +++++++++++++++++++++ lib/utils/dom-events.json | 85 +++++ tests/lib/rules/no-shadow-native-events.js | 112 +++++++ 3 files changed, 568 insertions(+) create mode 100644 lib/rules/no-shadow-native-events.js create mode 100644 lib/utils/dom-events.json create mode 100644 tests/lib/rules/no-shadow-native-events.js diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js new file mode 100644 index 000000000..3fc7b7411 --- /dev/null +++ b/lib/rules/no-shadow-native-events.js @@ -0,0 +1,371 @@ +/** + * @author Jonathan Carle + * See LICENSE file in root directory for full license. + */ +'use strict' + +const utils = require('../utils') +const domEvents = require('../utils/dom-events.json') +const { capitalize } = require('../utils/casing.js') +const { + findVariable, + isOpeningBraceToken, + isClosingBraceToken, + isOpeningBracketToken +} = require('@eslint-community/eslint-utils') +/** + * @typedef {import('../utils').ComponentEmit} ComponentEmit + * @typedef {import('../utils').ComponentProp} ComponentProp + * @typedef {import('../utils').VueObjectData} VueObjectData + */ + +/** + * Get the name param node from the given CallExpression + * @param {CallExpression} node CallExpression + * @returns { NameWithLoc | null } + */ +function getNameParamNode(node) { + const nameLiteralNode = node.arguments[0] + if (nameLiteralNode && utils.isStringLiteral(nameLiteralNode)) { + const name = utils.getStringLiteralValue(nameLiteralNode) + if (name != null) { + return { name, loc: nameLiteralNode.loc, range: nameLiteralNode.range } + } + } + + // cannot check + return null +} + +/** + * Check if the given name matches defineEmitsNode variable name + * @param {string} name + * @param {CallExpression | undefined} defineEmitsNode + * @returns {boolean} + */ +function isEmitVariableName(name, defineEmitsNode) { + const node = defineEmitsNode?.parent + + if (node?.type === 'VariableDeclarator' && node.id.type === 'Identifier') { + return name === node.id.name + } + + return false +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'disallow the use of event names that collide with native web event names', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/no-shadow-native-events.html' + }, + fixable: null, + hasSuggestions: false, + schema: [ + { + type: 'object', + properties: { + allowProps: { + type: 'boolean' + } + }, + additionalProperties: false + } + ], + messages: { + violation: + 'Use a different emit name to avoid shadowing native events: {{ name }}.' + } + }, + /** @param {RuleContext} context */ + create(context) { + const options = context.options[0] || {} + const allowProps = !!options.allowProps + /** @type {Map, emitReferenceIds: Set }>} */ + const setupContexts = new Map() + + /** + * @typedef {object} VueTemplateDefineData + * @property {'export' | 'mark' | 'definition' | 'setup'} type + * @property {ObjectExpression | Program} define + * @property {CallExpression} [defineEmits] + */ + /** @type {VueTemplateDefineData | null} */ + let vueTemplateDefineData = null + + // TODO: needed? + const programNode = context.getSourceCode().ast + if (utils.isScriptSetup(context)) { + // init + vueTemplateDefineData = { + type: 'setup', + define: programNode + } + } + + /** + * @param {NameWithLoc} nameWithLoc + */ + function verifyEmit(nameWithLoc) { + const name = nameWithLoc.name.toLowerCase() + if (!domEvents.includes(name)) { + return + } + context.report({ + loc: nameWithLoc.loc, + messageId: 'violation', + data: { + name + } + }) + } + + const callVisitor = { + /** + * @param {CallExpression} node + * @param {VueObjectData} [info] + */ + CallExpression(node, info) { + const callee = utils.skipChainExpression(node.callee) + const nameWithLoc = getNameParamNode(node) + if (!nameWithLoc) { + // cannot check + return + } + const vueDefineNode = info ? info.node : programNode + + let emit + if (callee.type === 'MemberExpression') { + const name = utils.getStaticPropertyName(callee) + if (name === 'emit' || name === '$emit') { + emit = { name, member: callee } + } + } + + // verify setup context + const setupContext = setupContexts.get(vueDefineNode) + if (setupContext) { + const { contextReferenceIds, emitReferenceIds } = setupContext + if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) { + // verify setup(props,{emit}) {emit()} + verifyEmit(nameWithLoc) + } else if (emit && emit.name === 'emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if ( + memObject.type === 'Identifier' && + contextReferenceIds.has(memObject) + ) { + // verify setup(props,context) {context.emit()} + verifyEmit(nameWithLoc) + } + } + } + + // verify $emit + if (emit && emit.name === '$emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if (utils.isThis(memObject, context)) { + // verify this.$emit() + verifyEmit(nameWithLoc) + } + } + } + } + /** + * @param {ComponentEmit[]} emits + */ + const verifyEmitDeclaration = (emits) => { + for (const { node, emitName } of emits) { + if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { + continue + } + + context.report({ + messageId: 'violation', + data: { name: emitName }, + loc: node.loc + }) + } + } + + return utils.compositingVisitors( + utils.defineTemplateBodyVisitor( + context, + { + /** @param { CallExpression } node */ + CallExpression(node) { + const callee = utils.skipChainExpression(node.callee) + const nameWithLoc = getNameParamNode(node) + if (!nameWithLoc) { + // cannot check + return + } + + // e.g. $emit() / emit() in template + if ( + callee.type === 'Identifier' && + (callee.name === '$emit' || + (vueTemplateDefineData?.defineEmits && + isEmitVariableName( + callee.name, + vueTemplateDefineData.defineEmits + ))) + ) { + verifyEmit(nameWithLoc) + } + } + }, + utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefineEmitsEnter: (node, emits) => { + verifyEmitDeclaration(emits) + + // TODO: needed? + if ( + vueTemplateDefineData && + vueTemplateDefineData.type === 'setup' + ) { + vueTemplateDefineData.defineEmits = node + } + + if ( + !node.parent || + node.parent.type !== 'VariableDeclarator' || + node.parent.init !== node + ) { + return + } + + const emitParam = node.parent.id + const variable = + emitParam.type === 'Identifier' + ? findVariable(utils.getScope(context, emitParam), emitParam) + : null + if (!variable) { + return + } + /** @type {Set} */ + const emitReferenceIds = new Set() + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + emitReferenceIds.add(reference.identifier) + } + setupContexts.set(programNode, { + contextReferenceIds: new Set(), + emitReferenceIds + }) + }, + onDefinePropsEnter: (node, props) => { + if (allowProps) { + // TODO: verify props + } + }, + ...callVisitor + }), + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node, { node: vueNode }) { + const contextParam = node.params[1] + if (!contextParam) { + // no arguments + return + } + if (contextParam.type === 'RestElement') { + // cannot check + return + } + if (contextParam.type === 'ArrayPattern') { + // cannot check + return + } + /** @type {Set} */ + const contextReferenceIds = new Set() + /** @type {Set} */ + const emitReferenceIds = new Set() + if (contextParam.type === 'ObjectPattern') { + const emitProperty = utils.findAssignmentProperty( + contextParam, + 'emit' + ) + if (!emitProperty) { + return + } + const emitParam = emitProperty.value + // `setup(props, {emit})` + const variable = + emitParam.type === 'Identifier' + ? findVariable( + utils.getScope(context, emitParam), + emitParam + ) + : null + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + emitReferenceIds.add(reference.identifier) + } + } else if (contextParam.type === 'Identifier') { + // `setup(props, context)` + const variable = findVariable( + utils.getScope(context, contextParam), + contextParam + ) + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + contextReferenceIds.add(reference.identifier) + } + } + setupContexts.set(vueNode, { + contextReferenceIds, + emitReferenceIds + }) + }, + onVueObjectEnter(node) { + const emits = utils.getComponentEmitsFromOptions(node) + verifyEmitDeclaration(emits) + + if (allowProps) { + // TODO: verify props + // utils.getComponentPropsFromOptions(node) + } + }, + onVueObjectExit(node, { type }) { + if ( + (!vueTemplateDefineData || + (vueTemplateDefineData.type !== 'export' && + vueTemplateDefineData.type !== 'setup')) && + (type === 'mark' || type === 'export' || type === 'definition') + ) { + vueTemplateDefineData = { + type, + define: node + } + } + setupContexts.delete(node) + }, + ...callVisitor + }) + ) + ) + ) + } +} diff --git a/lib/utils/dom-events.json b/lib/utils/dom-events.json new file mode 100644 index 000000000..c3ae47089 --- /dev/null +++ b/lib/utils/dom-events.json @@ -0,0 +1,85 @@ +[ + "copy", + "cut", + "paste", + "compositionend", + "compositionstart", + "compositionupdate", + "drag", + "dragend", + "dragenter", + "dragexit", + "dragleave", + "dragover", + "dragstart", + "drop", + "focus", + "focusin", + "focusout", + "blur", + "change", + "beforeinput", + "input", + "reset", + "submit", + "invalid", + "load", + "error", + "keydown", + "keypress", + "keyup", + "auxclick", + "click", + "contextmenu", + "dblclick", + "mousedown", + "mouseenter", + "mouseleave", + "mousemove", + "mouseout", + "mouseover", + "mouseup", + "abort", + "canplay", + "canplaythrough", + "durationchange", + "emptied", + "encrypted", + "ended", + "loadeddata", + "loadedmetadata", + "loadstart", + "pause", + "play", + "playing", + "progress", + "ratechange", + "seeked", + "seeking", + "stalled", + "suspend", + "timeupdate", + "volumechange", + "waiting", + "select", + "scroll", + "scrollend", + "touchcancel", + "touchend", + "touchmove", + "touchstart", + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "pointerenter", + "pointerleave", + "pointerover", + "pointerout", + "wheel", + "animationstart", + "animationend", + "animationiteration", + "transitionend", + "transitionstart" +] diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js new file mode 100644 index 000000000..76b442b14 --- /dev/null +++ b/tests/lib/rules/no-shadow-native-events.js @@ -0,0 +1,112 @@ +/** + * @author Jonathan Carle + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('../../eslint-compat').RuleTester +const rule = require('../../../lib/rules/no-shadow-native-events') + +const tester = new RuleTester({ + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('no-shadow-native-events', rule, { + valid: [], + invalid: [ + { + filename: 'test.vue', + code: ` + `, + languageOptions: { + parserOptions: { + parser: require.resolve('@typescript-eslint/parser') + } + }, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test2.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + }, + { + messageId: 'violation' + } + ] + }, + { + filename: 'test3.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test4.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test5.vue', + code: ` + `, + errors: [ + { + messageId: 'violation' + } + ] + }, + { + filename: 'test5.vue', + code: ` + + `, + + errors: [ + { + messageId: 'violation' + } + ] + } + ] +}) From 1c40d3be44f62ea72f8fb703a81945026ec659f5 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:00:49 +0200 Subject: [PATCH 16/32] added invalid tests --- lib/rules/no-shadow-native-events.js | 7 +- tests/lib/rules/no-shadow-native-events.js | 484 +++++++++++++++++++-- 2 files changed, 455 insertions(+), 36 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 3fc7b7411..42fef0492 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -90,6 +90,9 @@ module.exports = { /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() + /** @type {string[]} */ + const definedAndReportedEmits = [] + /** * @typedef {object} VueTemplateDefineData * @property {'export' | 'mark' | 'definition' | 'setup'} type @@ -99,7 +102,6 @@ module.exports = { /** @type {VueTemplateDefineData | null} */ let vueTemplateDefineData = null - // TODO: needed? const programNode = context.getSourceCode().ast if (utils.isScriptSetup(context)) { // init @@ -114,7 +116,7 @@ module.exports = { */ function verifyEmit(nameWithLoc) { const name = nameWithLoc.name.toLowerCase() - if (!domEvents.includes(name)) { + if (!domEvents.includes(name) || definedAndReportedEmits.includes(name)) { return } context.report({ @@ -186,6 +188,7 @@ module.exports = { continue } + definedAndReportedEmits.push(emitName) context.report({ messageId: 'violation', data: { name: emitName }, diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js index 76b442b14..a24b15f33 100644 --- a/tests/lib/rules/no-shadow-native-events.js +++ b/tests/lib/rules/no-shadow-native-events.js @@ -6,6 +6,9 @@ const RuleTester = require('../../eslint-compat').RuleTester const rule = require('../../../lib/rules/no-shadow-native-events') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ languageOptions: { @@ -21,90 +24,503 @@ tester.run('no-shadow-native-events', rule, { { filename: 'test.vue', code: ` - `, - languageOptions: { - parserOptions: { - parser: require.resolve('@typescript-eslint/parser') + + + `, + errors: [ + { + line: 3, + column: 28, + messageId: 'violation', + endLine: 3, + endColumn: 35 } - }, + ] + }, + { + filename: 'test.vue', + code: ` + + + `, errors: [ { - messageId: 'violation' + line: 7, + column: 17, + messageId: 'violation', + endLine: 7, + endColumn: 24 } ] }, { - filename: 'test2.vue', + filename: 'test.vue', code: ` - `, + + + `, errors: [ { - messageId: 'violation' - }, + line: 7, + column: 17, + messageId: 'violation', + endLine: 7, + endColumn: 26 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ { - messageId: 'violation' + line: 6, + column: 24, + messageId: 'violation', + endLine: 6, + endColumn: 31 } ] }, { - filename: 'test3.vue', + filename: 'test.vue', code: ` `, + export default { + emits: ['welcome'], + methods: { + onClick() { + const vm = this + vm.$emit('click') + } + } + } + + `, errors: [ { - messageId: 'violation' + line: 8, + column: 22, + messageId: 'violation', + endLine: 8, + endColumn: 29 } ] }, { - filename: 'test4.vue', + filename: 'test.vue', code: ` + `, + errors: [ + { + line: 5, + column: 24, + messageId: 'violation', + endLine: 5, + endColumn: 31 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 5, + column: 16, + messageId: 'violation', + endLine: 5, + endColumn: 23 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + line: 5, + column: 16, + messageId: 'violation', + endLine: 5, + endColumn: 23 }, - }; - `, + { + line: 6, + column: 16, + messageId: 'violation', + endLine: 6, + endColumn: 25 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, errors: [ { - messageId: 'violation' + messageId: 'violation', + line: 7, + column: 25, + endLine: 7, + endColumn: 32 + }, + { + messageId: 'violation', + line: 8, + column: 28, + endLine: 8, + endColumn: 37 } ] }, { - filename: 'test5.vue', + filename: 'test.vue', code: ` `, + + `, errors: [ { - messageId: 'violation' + messageId: 'violation', + line: 5, + column: 21, + endLine: 5, + endColumn: 28 + }, + { + messageId: 'violation', + line: 6, + column: 24, + endLine: 6, + endColumn: 33 } ] }, + // `, - errors: [ { - messageId: 'violation' + messageId: 'violation', + line: 6, + column: 20, + endLine: 6, + endColumn: 27 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 7, + column: 9, + endLine: 7, + endColumn: 27 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 6, + column: 19, + endLine: 6, + endColumn: 39 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + messageId: 'violation', + line: 3, + column: 28, + endLine: 3, + endColumn: 35 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 32, + endLine: 3, + endColumn: 52 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'violation', + line: 3, + column: 33, + endLine: 3, + endColumn: 40 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 33, + endLine: 3, + endColumn: 42 + }, + { + messageId: 'violation', + line: 5, + column: 12, + endLine: 5, + endColumn: 21 + } + ] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 21, + endLine: 3, + endColumn: 30 + }, + { + messageId: 'violation', + line: 6, + column: 12, + endLine: 6, + endColumn: 21 + } + ] + }, + { + code: ` + `, + errors: [ + { + messageId: 'violation', + line: 4, + column: 32, + endLine: 4, + endColumn: 37 + }, + { + messageId: 'violation', + line: 4, + column: 32, + endLine: 4, + endColumn: 37 + } + ], + ...getTypeScriptFixtureTestOptions() + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + messageId: 'violation', + line: 3, + column: 27, + endLine: 3, + endColumn: 36 + }, + { + messageId: 'violation', + line: 6, + column: 32, + endLine: 6, + endColumn: 52 } ] } From 8e2cfd70ed8f9defc98fea7b676078abd63bec39 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:04:07 +0200 Subject: [PATCH 17/32] added valid tests cases --- tests/lib/rules/no-shadow-native-events.js | 641 ++++++++++++++++++++- 1 file changed, 640 insertions(+), 1 deletion(-) diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js index a24b15f33..4af73fbb0 100644 --- a/tests/lib/rules/no-shadow-native-events.js +++ b/tests/lib/rules/no-shadow-native-events.js @@ -19,7 +19,646 @@ const tester = new RuleTester({ }) tester.run('no-shadow-native-events', rule, { - valid: [], + valid: [ + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + // quoted + { + filename: 'test.vue', + code: ` + + + ` + }, + // unknown + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + // allowProps + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + + // + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + + // unknown emits definition + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + + // unknown props definition + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + // new syntax in Vue 3.3 + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + } + ], invalid: [ { filename: 'test.vue', From 9d0e1c2f2e0de481df0c4c693bae67a0e51b78c5 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:51:43 +0200 Subject: [PATCH 18/32] clean up --- lib/rules/no-shadow-native-events.js | 34 +++------------------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 42fef0492..364c35eeb 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -6,13 +6,7 @@ const utils = require('../utils') const domEvents = require('../utils/dom-events.json') -const { capitalize } = require('../utils/casing.js') -const { - findVariable, - isOpeningBraceToken, - isClosingBraceToken, - isOpeningBracketToken -} = require('@eslint-community/eslint-utils') +const { findVariable } = require('@eslint-community/eslint-utils') /** * @typedef {import('../utils').ComponentEmit} ComponentEmit * @typedef {import('../utils').ComponentProp} ComponentProp @@ -67,26 +61,14 @@ module.exports = { }, fixable: null, hasSuggestions: false, - schema: [ - { - type: 'object', - properties: { - allowProps: { - type: 'boolean' - } - }, - additionalProperties: false - } - ], + schema: [], messages: { violation: - 'Use a different emit name to avoid shadowing native events: {{ name }}.' + 'Use a different emit name to avoid shadowing the native event with name "{{ name }}". Consider an emit name which communicates the users intent, if applicable.' } }, /** @param {RuleContext} context */ create(context) { - const options = context.options[0] || {} - const allowProps = !!options.allowProps /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() @@ -267,11 +249,6 @@ module.exports = { emitReferenceIds }) }, - onDefinePropsEnter: (node, props) => { - if (allowProps) { - // TODO: verify props - } - }, ...callVisitor }), utils.defineVueVisitor(context, { @@ -345,11 +322,6 @@ module.exports = { onVueObjectEnter(node) { const emits = utils.getComponentEmitsFromOptions(node) verifyEmitDeclaration(emits) - - if (allowProps) { - // TODO: verify props - // utils.getComponentPropsFromOptions(node) - } }, onVueObjectExit(node, { type }) { if ( From 84b7b78d1c6625eb091ad1e6cf4776f988016a3d Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:51:47 +0200 Subject: [PATCH 19/32] add docs --- docs/rules/no-shadow-native-events.md | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/rules/no-shadow-native-events.md diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md new file mode 100644 index 000000000..1e00637f9 --- /dev/null +++ b/docs/rules/no-shadow-native-events.md @@ -0,0 +1,50 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/no-shadow-native-events +description: disallow `emits` which would shadow native html events +--- +# vue/no-shadow-native-events + +> disallow `emits` which would shadow native HTML events + +- :exclamation: _**This rule has not been released yet.**_ + +## :book: Rule Details + +This rule reports emits that shadow native HTML events. (The `emits` option is a new in Vue.js 3.0.0+) + +Using native event names for emits can lead to incorrect assumptions about an emit and cause confusion. This is caused by Vue emits behaving differently from native events. E.g. : +- The payload of an emit can be chosen arbitrarily +- Vue emits do not bubble, while most native events do +- [Event modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers) only work on HTML events or when the original event is re-emitted as emit payload. +- When the native event is remitted the `event.target` might not match the actual event-listeners location. + +The rule is mostly aimed at developers of component libraries. + + + +```vue + +``` + + + +## :wrench: Options + +Nothing. + +## :couple: Related Rules + +- [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) +- [vue/require-explicit-emits](./require-explicit-emits.md) + +## :books: Further Reading + +- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) +- [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) From 7bdf176e91d857603e2886372d43ad0fb1ca50fb Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:52:28 +0200 Subject: [PATCH 20/32] update related docs --- docs/rules/require-explicit-emits.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index d04dec8b3..f00022b83 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -114,10 +114,11 @@ export default { - [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) - [vue/require-explicit-slots](./require-explicit-slots.md) +- [vue/no-shadow-native](./no-shadow-native.md) ## :books: Further Reading -- [Guide - Custom Events / Defining Custom Events](https://v3.vuejs.org/guide/component-custom-events.html#defining-custom-events) +- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) ## :rocket: Version From b10bae2b1e45ded1653d749364f2dac6621083c1 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:52:56 +0200 Subject: [PATCH 21/32] add missing test fixture --- tests/fixtures/typescript/src/test01.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/typescript/src/test01.ts b/tests/fixtures/typescript/src/test01.ts index d14550843..169dfb1c7 100644 --- a/tests/fixtures/typescript/src/test01.ts +++ b/tests/fixtures/typescript/src/test01.ts @@ -7,6 +7,10 @@ export type Emits1 = { (e: 'foo' | 'bar', payload: string): void (e: 'baz', payload: number): void } +export type Emits2 = { + (e: 'click' | 'bar', payload: string): void + (e: 'keydown', payload: number): void +} export type Props2 = { a: string b?: number From 2dc694942aa74ebbd53a3cf36be6f711b52bf1d6 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 11:53:39 +0200 Subject: [PATCH 22/32] fix link --- docs/rules/require-explicit-emits.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index f00022b83..38ac41448 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -114,7 +114,7 @@ export default { - [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md) - [vue/require-explicit-slots](./require-explicit-slots.md) -- [vue/no-shadow-native](./no-shadow-native.md) +- [vue/no-shadow-native-events](./no-shadow-native-events.md) ## :books: Further Reading From 9a8ccd7cc1d52be22c6571c741a3a020e06b5390 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 16:08:34 +0200 Subject: [PATCH 23/32] fix missing typedef for NameWithLoc --- lib/rules/no-shadow-native-events.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 364c35eeb..47d893e04 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -11,6 +11,7 @@ const { findVariable } = require('@eslint-community/eslint-utils') * @typedef {import('../utils').ComponentEmit} ComponentEmit * @typedef {import('../utils').ComponentProp} ComponentProp * @typedef {import('../utils').VueObjectData} VueObjectData + * @typedef {import('./require-explicit-emits.js').NameWithLoc} NameWithLoc */ /** From dc011e0ca723d81dbb5c20a81be541c3dd753731 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:06:42 +0200 Subject: [PATCH 24/32] lint issue: remove todo --- lib/rules/no-shadow-native-events.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 47d893e04..4d6dad71c 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -211,8 +211,6 @@ module.exports = { utils.defineScriptSetupVisitor(context, { onDefineEmitsEnter: (node, emits) => { verifyEmitDeclaration(emits) - - // TODO: needed? if ( vueTemplateDefineData && vueTemplateDefineData.type === 'setup' From 8c71413186aebf76a64c630e2580629ec67fcb99 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:09:50 +0200 Subject: [PATCH 25/32] fix lint issue: "space inside link text" --- docs/rules/no-shadow-native-events.md | 3 ++- docs/rules/require-explicit-emits.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md index 1e00637f9..843fa8aec 100644 --- a/docs/rules/no-shadow-native-events.md +++ b/docs/rules/no-shadow-native-events.md @@ -15,6 +15,7 @@ description: disallow `emits` which would shadow native html events This rule reports emits that shadow native HTML events. (The `emits` option is a new in Vue.js 3.0.0+) Using native event names for emits can lead to incorrect assumptions about an emit and cause confusion. This is caused by Vue emits behaving differently from native events. E.g. : + - The payload of an emit can be chosen arbitrarily - Vue emits do not bubble, while most native events do - [Event modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers) only work on HTML events or when the original event is re-emitted as emit payload. @@ -46,5 +47,5 @@ Nothing. ## :books: Further Reading -- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) +- [Components In-Depth - Events / Component Events](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index 38ac41448..507f47398 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -118,7 +118,7 @@ export default { ## :books: Further Reading -- [Components In-Depth - Events / Component Events ](https://vuejs.org/guide/components/events.html#event-arguments) +- [Components In-Depth - Events / Component Events](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) ## :rocket: Version From f3ffb7d6add045615f0d1f19215865be1f445aa1 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:13:22 +0200 Subject: [PATCH 26/32] remove unused options --- tests/lib/rules/no-shadow-native-events.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/lib/rules/no-shadow-native-events.js b/tests/lib/rules/no-shadow-native-events.js index 4af73fbb0..63ccca7bc 100644 --- a/tests/lib/rules/no-shadow-native-events.js +++ b/tests/lib/rules/no-shadow-native-events.js @@ -395,8 +395,7 @@ tester.run('no-shadow-native-events', rule, { } } - `, - options: [{ allowProps: true }] + ` }, // - `, - options: [{ allowProps: true }] + ` }, { filename: 'test.vue', @@ -587,8 +585,7 @@ tester.run('no-shadow-native-events', rule, { - `, - options: [{ allowProps: true }] + ` }, { filename: 'test.vue', @@ -601,8 +598,7 @@ tester.run('no-shadow-native-events', rule, { props: [foo], } - `, - options: [{ allowProps: true }] + ` }, { filename: 'test.vue', @@ -615,8 +611,7 @@ tester.run('no-shadow-native-events', rule, { props: [...foo], } - `, - options: [{ allowProps: true }] + ` }, { // new syntax in Vue 3.3 From 066689bb22df07eb540c866186a0272d5e87cc43 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:19:31 +0200 Subject: [PATCH 27/32] replace array with Set --- lib/rules/no-shadow-native-events.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 4d6dad71c..8a3aa3929 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -73,8 +73,8 @@ module.exports = { /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() - /** @type {string[]} */ - const definedAndReportedEmits = [] + /** @type {Set} */ + const definedAndReportedEmits = new Set() /** * @typedef {object} VueTemplateDefineData @@ -99,7 +99,7 @@ module.exports = { */ function verifyEmit(nameWithLoc) { const name = nameWithLoc.name.toLowerCase() - if (!domEvents.includes(name) || definedAndReportedEmits.includes(name)) { + if (!domEvents.includes(name) || definedAndReportedEmits.has(name)) { return } context.report({ @@ -171,7 +171,7 @@ module.exports = { continue } - definedAndReportedEmits.push(emitName) + definedAndReportedEmits.add(emitName) context.report({ messageId: 'violation', data: { name: emitName }, From 7941f9c14fee7cee3c049ec2b348b30720df3309 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Mon, 16 Sep 2024 19:33:28 +0200 Subject: [PATCH 28/32] add explanatory comments --- lib/rules/no-shadow-native-events.js | 44 +++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/lib/rules/no-shadow-native-events.js b/lib/rules/no-shadow-native-events.js index 8a3aa3929..b13be9c6f 100644 --- a/lib/rules/no-shadow-native-events.js +++ b/lib/rules/no-shadow-native-events.js @@ -60,8 +60,6 @@ module.exports = { categories: undefined, url: 'https://eslint.vuejs.org/rules/no-shadow-native-events.html' }, - fixable: null, - hasSuggestions: false, schema: [], messages: { violation: @@ -73,7 +71,10 @@ module.exports = { /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() - /** @type {Set} */ + /** + * Tracks violating emit definitions, so that calls of this emit are not reported additionally. + * @type {Set} + * */ const definedAndReportedEmits = new Set() /** @@ -95,6 +96,7 @@ module.exports = { } /** + * Verify if an emit call violates the rule of not using a native dom event name. * @param {NameWithLoc} nameWithLoc */ function verifyEmit(nameWithLoc) { @@ -111,6 +113,25 @@ module.exports = { }) } + /** + * Verify if an emit declaration violates the rule of not using a native dom event name. + * @param {ComponentEmit[]} emits + */ + const verifyEmitDeclaration = (emits) => { + for (const { node, emitName } of emits) { + if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { + continue + } + + definedAndReportedEmits.add(emitName) + context.report({ + messageId: 'violation', + data: { name: emitName }, + loc: node.loc + }) + } + } + const callVisitor = { /** * @param {CallExpression} node @@ -162,23 +183,6 @@ module.exports = { } } } - /** - * @param {ComponentEmit[]} emits - */ - const verifyEmitDeclaration = (emits) => { - for (const { node, emitName } of emits) { - if (!node || !emitName || !domEvents.includes(emitName.toLowerCase())) { - continue - } - - definedAndReportedEmits.add(emitName) - context.report({ - messageId: 'violation', - data: { name: emitName }, - loc: node.loc - }) - } - } return utils.compositingVisitors( utils.defineTemplateBodyVisitor( From 3901c990bace07286754b4a9b5f92d337036cd5e Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Thu, 19 Sep 2024 17:59:28 +0200 Subject: [PATCH 29/32] review: execute "npm run update" --- docs/rules/index.md | 1 + docs/rules/no-shadow-native-events.md | 10 ++++++++-- lib/index.js | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/rules/index.md b/docs/rules/index.md index 1ba5978d2..a88f14e81 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -250,6 +250,7 @@ For example: | [vue/no-restricted-v-on](./no-restricted-v-on.md) | disallow specific argument in `v-on` | | :hammer: | | [vue/no-root-v-if](./no-root-v-if.md) | disallow `v-if` directives on root element | | :hammer: | | [vue/no-setup-props-reactivity-loss](./no-setup-props-reactivity-loss.md) | disallow usages that lose the reactivity of `props` passed to `setup` | | :hammer: | +| [vue/no-shadow-native-events](./no-shadow-native-events.md) | disallow the use of event names that collide with native web event names | | :warning: | | [vue/no-static-inline-styles](./no-static-inline-styles.md) | disallow static inline `style` attributes | | :hammer: | | [vue/no-template-target-blank](./no-template-target-blank.md) | disallow target="_blank" attribute without rel="noopener noreferrer" | :bulb: | :warning: | | [vue/no-this-in-before-route-enter](./no-this-in-before-route-enter.md) | disallow `this` usage in a `beforeRouteEnter` method | | :warning: | diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md index 843fa8aec..bccf93d4d 100644 --- a/docs/rules/no-shadow-native-events.md +++ b/docs/rules/no-shadow-native-events.md @@ -2,11 +2,12 @@ pageClass: rule-details sidebarDepth: 0 title: vue/no-shadow-native-events -description: disallow `emits` which would shadow native html events +description: disallow the use of event names that collide with native web event names --- + # vue/no-shadow-native-events -> disallow `emits` which would shadow native HTML events +> disallow the use of event names that collide with native web event names - :exclamation: _**This rule has not been released yet.**_ @@ -49,3 +50,8 @@ Nothing. - [Components In-Depth - Events / Component Events](https://vuejs.org/guide/components/events.html#event-arguments) - [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md) + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-shadow-native-events.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-shadow-native-events.js) diff --git a/lib/index.js b/lib/index.js index bb4abf40f..01dd41745 100644 --- a/lib/index.js +++ b/lib/index.js @@ -160,6 +160,7 @@ const plugin = { 'no-root-v-if': require('./rules/no-root-v-if'), 'no-setup-props-destructure': require('./rules/no-setup-props-destructure'), 'no-setup-props-reactivity-loss': require('./rules/no-setup-props-reactivity-loss'), + 'no-shadow-native-events': require('./rules/no-shadow-native-events'), 'no-shared-component-data': require('./rules/no-shared-component-data'), 'no-side-effects-in-computed-properties': require('./rules/no-side-effects-in-computed-properties'), 'no-spaces-around-equal-signs-in-attribute': require('./rules/no-spaces-around-equal-signs-in-attribute'), From b62c161022f1db18246dfcb8dacfd49e20cd3e6c Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 19 Sep 2024 18:03:04 +0200 Subject: [PATCH 30/32] Update docs/rules/no-shadow-native-events.md Co-authored-by: Flo Edelmann --- docs/rules/no-shadow-native-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md index bccf93d4d..c548f6bad 100644 --- a/docs/rules/no-shadow-native-events.md +++ b/docs/rules/no-shadow-native-events.md @@ -13,7 +13,7 @@ description: disallow the use of event names that collide with native web event ## :book: Rule Details -This rule reports emits that shadow native HTML events. (The `emits` option is a new in Vue.js 3.0.0+) +This rule reports emits that shadow native HTML events. Using native event names for emits can lead to incorrect assumptions about an emit and cause confusion. This is caused by Vue emits behaving differently from native events. E.g. : From 4feb8343fc1e66ec51b94e71e3e82bfae9468038 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 19 Sep 2024 18:03:29 +0200 Subject: [PATCH 31/32] Update docs/rules/no-shadow-native-events.md Co-authored-by: Flo Edelmann --- docs/rules/no-shadow-native-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/no-shadow-native-events.md b/docs/rules/no-shadow-native-events.md index c548f6bad..f9581f4b9 100644 --- a/docs/rules/no-shadow-native-events.md +++ b/docs/rules/no-shadow-native-events.md @@ -20,7 +20,7 @@ Using native event names for emits can lead to incorrect assumptions about an em - The payload of an emit can be chosen arbitrarily - Vue emits do not bubble, while most native events do - [Event modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers) only work on HTML events or when the original event is re-emitted as emit payload. -- When the native event is remitted the `event.target` might not match the actual event-listeners location. +- When the native event is re-emitted, the `event.target` might not match the actual event-listeners location. The rule is mostly aimed at developers of component libraries. From 3e9e020560f091de2e1c8b1fa95e55cc01a122d0 Mon Sep 17 00:00:00 2001 From: Jonathan Carle Date: Fri, 20 Sep 2024 08:26:30 +0200 Subject: [PATCH 32/32] review: revert accidental packageManager field in package.json --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index c99538cf8..6ffc88fb6 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,5 @@ "prettier": "^3.3.3", "typescript": "^5.5.4", "vitepress": "^1.3.1" - }, - "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1" + } }