diff --git a/.changeset/happy-corners-shop.md b/.changeset/happy-corners-shop.md new file mode 100644 index 000000000..d3b649da2 --- /dev/null +++ b/.changeset/happy-corners-shop.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-vue': minor +--- + +Added new [`vue/require-mayberef-unwrap`](https://eslint.vuejs.org/rules/require-mayberef-unwrap.html) rule diff --git a/docs/rules/index.md b/docs/rules/index.md index 6423ef365..81c2cf7d4 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -97,6 +97,7 @@ Rules in this category are enabled for all presets provided by eslint-plugin-vue | [vue/no-watch-after-await] | disallow asynchronously registered `watch` | | :three::hammer: | | [vue/prefer-import-from-vue] | enforce import from 'vue' instead of import from '@vue/*' | :wrench: | :three::hammer: | | [vue/require-component-is] | require `v-bind:is` of `` elements | | :three::two::warning: | +| [vue/require-mayberef-unwrap] | require unwrapping `MaybeRef` values with `unref()` in conditions | :wrench: | :three::warning: | | [vue/require-prop-type-constructor] | require prop type to be a constructor | :wrench: | :three::two::hammer: | | [vue/require-render-return] | enforce render function to always return value | | :three::two::warning: | | [vue/require-slots-as-functions] | enforce properties of `$slots` to be used as a function | | :three::warning: | @@ -564,6 +565,7 @@ The following rules extend the rules provided by ESLint itself and apply them to [vue/require-explicit-slots]: ./require-explicit-slots.md [vue/require-expose]: ./require-expose.md [vue/require-macro-variable-name]: ./require-macro-variable-name.md +[vue/require-mayberef-unwrap]: ./require-mayberef-unwrap.md [vue/require-name-property]: ./require-name-property.md [vue/require-prop-comment]: ./require-prop-comment.md [vue/require-prop-type-constructor]: ./require-prop-type-constructor.md diff --git a/docs/rules/require-mayberef-unwrap.md b/docs/rules/require-mayberef-unwrap.md new file mode 100644 index 000000000..a7298c340 --- /dev/null +++ b/docs/rules/require-mayberef-unwrap.md @@ -0,0 +1,72 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/require-mayberef-unwrap +description: require `MaybeRef` values to be unwrapped with `unref()` before using in conditions +since: v10.3.0 +--- + +# vue/require-mayberef-unwrap + +> require unwrapping `MaybeRef` values with `unref()` in conditions + +- :gear: This rule is included in all of `"plugin:vue/essential"`, `*.configs["flat/essential"]`, `"plugin:vue/strongly-recommended"`, `*.configs["flat/strongly-recommended"]`, `"plugin:vue/recommended"` and `*.configs["flat/recommended"]`. +- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fix-problems) can automatically fix some of the problems reported by this rule. + +## :book: Rule Details + +This rule reports cases where a `MaybeRef` value is used incorrectly in conditions. +You must use `unref()` to access the inner value. + + + +```vue + +``` + + + +## :wrench: Options + +Nothing. + +This rule also applies to `MaybeRefOrGetter` values in addition to `MaybeRef`. + +## :books: Further Reading + +- [Guide – Reactivity – `unref`](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#unref) +- [API – `MaybeRef`](https://vuejs.org/api/utility-types.html#mayberef) +- [API – `MaybeRefOrGetter`](https://vuejs.org/api/utility-types.html#maybereforgetter) + +## :rocket: Version + +This rule was introduced in eslint-plugin-vue v10.3.0 + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-mayberef-unwrap.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-mayberef-unwrap.js) diff --git a/lib/configs/flat/vue3-essential.js b/lib/configs/flat/vue3-essential.js index ff8b5b4a6..a54227173 100644 --- a/lib/configs/flat/vue3-essential.js +++ b/lib/configs/flat/vue3-essential.js @@ -63,6 +63,7 @@ module.exports = [ 'vue/no-watch-after-await': 'error', 'vue/prefer-import-from-vue': 'error', 'vue/require-component-is': 'error', + 'vue/require-mayberef-unwrap': 'error', 'vue/require-prop-type-constructor': 'error', 'vue/require-render-return': 'error', 'vue/require-slots-as-functions': 'error', diff --git a/lib/configs/vue3-essential.js b/lib/configs/vue3-essential.js index 34b3229b1..a95eadaa2 100644 --- a/lib/configs/vue3-essential.js +++ b/lib/configs/vue3-essential.js @@ -58,6 +58,7 @@ module.exports = { 'vue/no-watch-after-await': 'error', 'vue/prefer-import-from-vue': 'error', 'vue/require-component-is': 'error', + 'vue/require-mayberef-unwrap': 'error', 'vue/require-prop-type-constructor': 'error', 'vue/require-render-return': 'error', 'vue/require-slots-as-functions': 'error', diff --git a/lib/index.js b/lib/index.js index e511536fa..0026d37e5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -223,6 +223,7 @@ const plugin = { 'require-explicit-slots': require('./rules/require-explicit-slots'), 'require-expose': require('./rules/require-expose'), 'require-macro-variable-name': require('./rules/require-macro-variable-name'), + 'require-mayberef-unwrap': require('./rules/require-mayberef-unwrap'), 'require-name-property': require('./rules/require-name-property'), 'require-prop-comment': require('./rules/require-prop-comment'), 'require-prop-type-constructor': require('./rules/require-prop-type-constructor'), diff --git a/lib/rules/require-mayberef-unwrap.js b/lib/rules/require-mayberef-unwrap.js new file mode 100644 index 000000000..276c41e08 --- /dev/null +++ b/lib/rules/require-mayberef-unwrap.js @@ -0,0 +1,278 @@ +/** + * @author 2nofa11 + * See LICENSE file in root directory for full license. + */ +'use strict' + +const utils = require('../utils') + +/** + * Check TypeScript type node for MaybeRef/MaybeRefOrGetter + * @param {import('@typescript-eslint/types').TSESTree.TypeNode | undefined} typeNode + * @returns {boolean} + */ +function isMaybeRefTypeNode(typeNode) { + if (!typeNode) return false + if ( + typeNode.type === 'TSTypeReference' && + typeNode.typeName && + typeNode.typeName.type === 'Identifier' + ) { + return ( + typeNode.typeName.name === 'MaybeRef' || + typeNode.typeName.name === 'MaybeRefOrGetter' + ) + } + if (typeNode.type === 'TSUnionType') { + return typeNode.types.some((t) => isMaybeRefTypeNode(t)) + } + return false +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'require `MaybeRef` values to be unwrapped with `unref()` before using in conditions', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/require-mayberef-unwrap.html' + }, + fixable: 'code', + schema: [], + messages: { + requireUnref: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref({{name}})` instead.' + } + }, + /** @param {RuleContext} context */ + create(context) { + const filename = context.getFilename() + if (!utils.isVueFile(filename) && !utils.isTypeScriptFile(filename)) { + return {} + } + + /** @type {Map>} */ + const maybeRefPropsMap = new Map() + + /** + * Determine if identifier should be considered MaybeRef + * @param {Identifier} node + */ + function isMaybeRef(node) { + const variable = utils.findVariableByIdentifier(context, node) + if (!variable) { + return false + } + + const definition = variable.defs[0] + if (definition.type !== 'Variable') { + return false + } + + const id = definition.node?.id + if (!id || id.type !== 'Identifier' || !id.typeAnnotation) { + return false + } + + return isMaybeRefTypeNode(id.typeAnnotation.typeAnnotation) + } + + /** + * Check if MemberExpression accesses a MaybeRef prop + * @param {Identifier} objectNode + * @param {string} propertyName + */ + function isMaybeRefPropsAccess(objectNode, propertyName) { + if (!propertyName) { + return false + } + + const variable = utils.findVariableByIdentifier(context, objectNode) + if (!variable) { + return false + } + + const maybeRefProps = maybeRefPropsMap.get(variable.name) + return maybeRefProps ? maybeRefProps.has(propertyName) : false + } + + /** + * Reports if the identifier is a MaybeRef type + * @param {Identifier} node + * @param {string} [customName] Custom name for error message + */ + function reportIfMaybeRef(node, customName) { + if (!isMaybeRef(node)) { + return + } + + const sourceCode = context.getSourceCode() + context.report({ + node, + messageId: 'requireUnref', + data: { name: customName || node.name }, + fix(fixer) { + return fixer.replaceText(node, `unref(${sourceCode.getText(node)})`) + } + }) + } + + /** + * Reports if the MemberExpression accesses a MaybeRef prop + * @param {MemberExpression} node + */ + function reportIfMaybeRefProps(node) { + if (node.object.type !== 'Identifier') { + return + } + + const propertyName = utils.getStaticPropertyName(node) + if (!propertyName) { + return + } + + if (!isMaybeRefPropsAccess(node.object, propertyName)) { + return + } + + const sourceCode = context.getSourceCode() + context.report({ + node: node.property, + messageId: 'requireUnref', + data: { name: `${node.object.name}.${propertyName}` }, + fix(fixer) { + return fixer.replaceText(node, `unref(${sourceCode.getText(node)})`) + } + }) + } + + return utils.compositingVisitors( + { + // if (maybeRef) + /** @param {Identifier} node */ + 'IfStatement>Identifier'(node) { + reportIfMaybeRef(node) + }, + // maybeRef ? x : y + /** @param {Identifier & {parent: ConditionalExpression}} node */ + 'ConditionalExpression>Identifier'(node) { + if (node.parent.test !== node) { + return + } + reportIfMaybeRef(node) + }, + // !maybeRef, +maybeRef, -maybeRef, ~maybeRef, typeof maybeRef + /** @param {Identifier} node */ + 'UnaryExpression>Identifier'(node) { + reportIfMaybeRef(node) + }, + // maybeRef || other, maybeRef && other, maybeRef ?? other + /** @param {Identifier & {parent: LogicalExpression}} node */ + 'LogicalExpression>Identifier'(node) { + reportIfMaybeRef(node) + }, + // maybeRef == x, maybeRef != x, maybeRef === x, maybeRef !== x + /** @param {Identifier} node */ + 'BinaryExpression>Identifier'(node) { + reportIfMaybeRef(node) + }, + // Boolean(maybeRef), String(maybeRef) + /** @param {Identifier} node */ + 'CallExpression>Identifier'(node) { + const parent = node.parent + if (parent?.type !== 'CallExpression') return + + const callee = parent.callee + if (callee?.type !== 'Identifier') return + + if (!['Boolean', 'String'].includes(callee.name)) return + + if (parent.arguments[0] === node) { + reportIfMaybeRef(node) + } + }, + // props.maybeRefProp + /** @param {MemberExpression} node */ + MemberExpression(node) { + reportIfMaybeRefProps(node) + } + }, + utils.defineScriptSetupVisitor(context, { + onDefinePropsEnter(node, props) { + if ( + !node.parent || + node.parent.type !== 'VariableDeclarator' || + node.parent.init !== node + ) { + return + } + + const propsParam = node.parent.id + if (propsParam.type !== 'Identifier') { + return + } + + const maybeRefProps = new Set() + for (const prop of props) { + if (prop.type !== 'type' || !prop.node) { + continue + } + + if ( + prop.node.type !== 'TSPropertySignature' || + !prop.node.typeAnnotation + ) { + continue + } + + const typeAnnotation = prop.node.typeAnnotation.typeAnnotation + if (isMaybeRefTypeNode(typeAnnotation)) { + maybeRefProps.add(prop.propName) + } + } + + if (maybeRefProps.size > 0) { + maybeRefPropsMap.set(propsParam.name, maybeRefProps) + } + } + }), + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node) { + const propsParam = utils.skipDefaultParamValue(node.params[0]) + if (!propsParam || propsParam.type !== 'Identifier') { + return + } + + if (!propsParam.typeAnnotation) { + return + } + + const typeAnnotation = propsParam.typeAnnotation.typeAnnotation + const maybeRefProps = new Set() + + if (typeAnnotation.type === 'TSTypeLiteral') { + for (const member of typeAnnotation.members) { + if ( + member.type === 'TSPropertySignature' && + member.key && + member.key.type === 'Identifier' && + member.typeAnnotation && + isMaybeRefTypeNode(member.typeAnnotation.typeAnnotation) + ) { + maybeRefProps.add(member.key.name) + } + } + } + + if (maybeRefProps.size > 0) { + maybeRefPropsMap.set(propsParam.name, maybeRefProps) + } + }, + onVueObjectExit() { + maybeRefPropsMap.clear() + } + }) + ) + } +} diff --git a/tests/lib/rules/require-mayberef-unwrap.js b/tests/lib/rules/require-mayberef-unwrap.js new file mode 100644 index 000000000..98f48be98 --- /dev/null +++ b/tests/lib/rules/require-mayberef-unwrap.js @@ -0,0 +1,420 @@ +/** + * @author 2nofa11 + * See LICENSE file in root directory for full license. + */ +'use strict' + +// Import required modules for testing +const RuleTester = require('../../eslint-compat').RuleTester +const rule = require('../../../lib/rules/require-mayberef-unwrap') + +// Configure RuleTester with TypeScript and Vue parser settings +const tester = new RuleTester({ + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2020, + sourceType: 'module', + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } +}) + +// Execute test suite for require-mayberef-unwrap rule +tester.run('require-mayberef-unwrap', rule, { + // Valid test cases - these should not trigger the rule + valid: [ + { + // Test case: Basic MaybeRef with proper unref usage + filename: 'test.vue', + code: `` + }, + + { + // Test case: MaybeRefOrGetter type with proper unref usage + filename: 'test.vue', + code: `` + }, + + { + // Test case: Union type including MaybeRef with proper unref usage + filename: 'test.vue', + code: `` + }, + + { + // Test case: Conditional expression with proper unref usage + filename: 'test.vue', + code: `` + }, + + { + // Test case: Variable used in non-conditional context + filename: 'test.vue', + code: `` + }, + + { + // Test case: No TypeScript, so no type checking + filename: 'test.vue', + code: `` + }, + + { + // Test case: Non-MaybeRef type variable + filename: 'test.vue', + code: `` + }, + + { + // Test case: Array type, not MaybeRef + filename: 'test.vue', + code: `` + }, + + { + // Test case: Props without MaybeRef type + filename: 'test.vue', + code: `` + }, + + { + // Test case: Options API without type annotations + filename: 'test.vue', + code: ` + ` + }, + + { + // Test case: Options API with untyped props + filename: 'test.vue', + code: ` + ` + }, + + { + // Test case: Props definition without usage + filename: 'test.vue', + code: `` + }, + + { + // Test case: Destructured props with MaybeRef type + filename: 'test.vue', + code: `` + } + ], + + // Invalid test cases - these should trigger the rule and be auto-fixed + invalid: [ + { + // Test case: Basic MaybeRef without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 13 + } + ] + }, + + { + // Test case: MaybeRefOrGetter without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRefOrGetter)` instead.', + line: 4, + column: 13 + } + ] + }, + + { + // Test case: Union type including MaybeRef without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 13 + } + ] + }, + + { + // Test case: Conditional expression without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 24 + } + ] + }, + + { + // Test case: Unary expression without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 25 + } + ] + }, + + { + // Test case: Typeof expression without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 31 + } + ] + }, + + { + // Test case: Logical OR expression without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 24 + } + ] + }, + + { + // Test case: Equality comparison without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 24 + } + ] + }, + + { + // Test case: Boolean constructor without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(maybeRef)` instead.', + line: 4, + column: 32 + } + ] + }, + + { + // Test case: Props with MaybeRef type without unref + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(props.count)` instead.', + line: 4, + column: 19 + } + ] + }, + + { + // Test case: Options API with typed props without unref + filename: 'test.vue', + code: ` + `, + output: ` + `, + errors: [ + { + message: + 'MaybeRef should be unwrapped with `unref()` before using in conditions. Use `unref(props.count)` instead.', + line: 6, + column: 23 + } + ] + } + ] +})