diff --git a/.changeset/support-composable-ref-detection.md b/.changeset/support-composable-ref-detection.md new file mode 100644 index 000000000..fa99e6752 --- /dev/null +++ b/.changeset/support-composable-ref-detection.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-vue': minor +--- + +Enhanced `vue/no-ref-as-operand` rule to detect ref objects returned from composable functions diff --git a/lib/utils/ref-object-references.js b/lib/utils/ref-object-references.js index fe6f26117..c37d5abc0 100644 --- a/lib/utils/ref-object-references.js +++ b/lib/utils/ref-object-references.js @@ -275,6 +275,72 @@ function* iterateIdentifierReferences(id, globalScope) { } } +/** + * Check if a function returns a ref() call + * @param {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} node + * @returns {boolean} + */ +function checkFunctionReturnsRef(node) { + const body = node.body + if (!body) { + return false + } + + // For arrow functions with expression body + if ( + node.type === 'ArrowFunctionExpression' && + body.type !== 'BlockStatement' + ) { + return isRefCall(body) + } + + // For function declarations and arrow functions with block body + if (body.type === 'BlockStatement') { + for (const stmt of body.body) { + if (stmt.type === 'ReturnStatement' && stmt.argument) { + return isRefCall(stmt.argument) + } + } + } + + return false +} + +/** + * Check if an expression is a ref() call or returns a ref object + * @param {Expression} expr + * @returns {boolean} + */ +function isRefCall(expr) { + // Direct ref() call + if (expr.type === 'CallExpression') { + const callee = expr.callee + if (callee.type === 'Identifier' && callee.name === 'ref') { + return true + } + } + + // Object with ref properties: { data: ref(...) } + if (expr.type === 'ObjectExpression') { + for (const prop of expr.properties) { + if (prop.type === 'Property' && prop.value && isRefCall(prop.value)) { + return true + } + } + } + + // Array with ref items: [ref(...)] + if (expr.type === 'ArrayExpression') { + for (const element of expr.elements) { + if (element && element.type !== 'SpreadElement' && isRefCall(element)) { + return true + } + } + } + + return false +} + /** * @param {RuleContext} context The rule context. */ @@ -415,6 +481,35 @@ class RefObjectReferenceExtractor { this.processPattern(pattern, ctx) } + /** + * Process composable function calls that return Ref + * @param {CallExpression} node + * @param {string} composableName + */ + processComposableRefCall(node, composableName) { + const parent = node.parent + /** @type {Pattern | null} */ + let pattern = null + if (parent.type === 'VariableDeclarator') { + pattern = parent.id + } else if ( + parent.type === 'AssignmentExpression' && + parent.operator === '=' + ) { + pattern = parent.left + } else { + return + } + + const ctx = { + method: composableName, + define: node, + defineChain: [node] + } + + this.processPattern(pattern, ctx) + } + /** * @param {MemberExpression | Identifier} node * @param {RefObjectReferenceContext} ctx @@ -547,6 +642,93 @@ function extractRefObjectReferences(context) { references.processDefineModel(node) } + // Process composable functions that return Ref by analyzing all function definitions + // Build a map of functions that return Ref by checking all scopes + const refReturningFunctions = new Map() + + /** + * @param {import('eslint').Scope.Scope} scope + */ + function findRefReturningFunctions(scope) { + for (const variable of scope.variables) { + if (variable.defs.length === 1) { + const def = variable.defs[0] + // Function declaration + if (def.type === 'FunctionName') { + const node = def.node + if (checkFunctionReturnsRef(node)) { + refReturningFunctions.set(variable.name, node) + } + } + // Variable with function expression + else if (def.type === 'Variable' && def.node.init) { + const init = def.node.init + if ( + (init.type === 'FunctionExpression' || + init.type === 'ArrowFunctionExpression') && + checkFunctionReturnsRef(init) + ) { + refReturningFunctions.set(variable.name, init) + } + } + } + } + } + + // Search all scopes for function definitions + const allScopes = sourceCode.scopeManager + ? sourceCode.scopeManager.scopes + : [] + for (const scope of allScopes) { + findRefReturningFunctions(scope) + } + if (!sourceCode.scopeManager) { + findRefReturningFunctions(globalScope) + } + + // Now find all calls to these functions and process them + // We need to search through all variables, not just globalScope.variables + const searchedVariables = new Set() + + for (const scope of allScopes) { + for (const variable of scope.variables) { + if (!searchedVariables.has(variable.name)) { + searchedVariables.add(variable.name) + if (refReturningFunctions.has(variable.name)) { + for (const ref of variable.references) { + const parent = ref.identifier.parent + // Check if this is a call expression to a composable function that returns Ref + if ( + parent && + parent.type === 'CallExpression' && + parent.callee === ref.identifier + ) { + references.processComposableRefCall(parent, variable.name) + } + } + } + } + } + } + + if (!sourceCode.scopeManager) { + for (const variable of globalScope.variables) { + if (refReturningFunctions.has(variable.name)) { + for (const ref of variable.references) { + const parent = ref.identifier.parent + // Check if this is a call expression to a composable function that returns Ref + if ( + parent && + parent.type === 'CallExpression' && + parent.callee === ref.identifier + ) { + references.processComposableRefCall(parent, variable.name) + } + } + } + } + } + cacheForRefObjectReferences.set(sourceCode.ast, references) return references diff --git a/tests/lib/rules/no-ref-as-operand.js b/tests/lib/rules/no-ref-as-operand.js index 86cd7bd54..80cb728ca 100644 --- a/tests/lib/rules/no-ref-as-operand.js +++ b/tests/lib/rules/no-ref-as-operand.js @@ -307,6 +307,44 @@ tester.run('no-ref-as-operand', rule, { } }) + `, + ` + import { ref } from 'vue' + + function useCount() { + return ref(0) + } + + const count = useCount() + console.log(count.value) + `, + ` + import { ref } from 'vue' + + const useList = () => ref([]) + + const list = useList() + console.log(list.value) + `, + ` + import { ref } from 'vue' + + function useMultiple() { + return [ref(0), ref(1)] + } + + const [a, b] = useMultiple() + console.log(a.value, b.value) + `, + ` + import { ref } from 'vue' + + function useRef() { + return ref(0) + } + + const count = useRef() + count.value++ ` ], invalid: [ @@ -1249,6 +1287,66 @@ tester.run('no-ref-as-operand', rule, { endColumn: 28 } ] + }, + { + code: ` + import { ref } from 'vue' + + function useCount() { + return ref(0) + } + + const count = useCount() + count++ // error + `, + output: ` + import { ref } from 'vue' + + function useCount() { + return ref(0) + } + + const count = useCount() + count.value++ // error + `, + errors: [ + { + message: + 'Must use `.value` to read or write the value wrapped by `useCount()`.', + line: 9, + column: 7, + endLine: 9, + endColumn: 12 + } + ] + }, + { + code: ` + import { ref } from 'vue' + + const useList = () => ref([]) + + const list = useList() + list + 1 // error + `, + output: ` + import { ref } from 'vue' + + const useList = () => ref([]) + + const list = useList() + list.value + 1 // error + `, + errors: [ + { + message: + 'Must use `.value` to read or write the value wrapped by `useList()`.', + line: 7, + column: 7, + endLine: 7, + endColumn: 11 + } + ] } ] })