Skip to content

Commit

Permalink
Update vue/no-side-effects-in-computed-properties rule to support `…
Browse files Browse the repository at this point in the history
…<script setup>` (#1534)
  • Loading branch information
ota-meshi committed Jul 2, 2021
1 parent 2d4c49c commit 4442509
Show file tree
Hide file tree
Showing 3 changed files with 262 additions and 116 deletions.
237 changes: 121 additions & 116 deletions lib/rules/no-side-effects-in-computed-properties.js
Expand Up @@ -8,6 +8,7 @@ const utils = require('../utils')

/**
* @typedef {import('../utils').VueObjectData} VueObjectData
* @typedef {import('../utils').VueVisitor} VueVisitor
* @typedef {import('../utils').ComponentComputedProperty} ComponentComputedProperty
*/

Expand All @@ -32,8 +33,8 @@ module.exports = {
const computedPropertiesMap = new Map()
/** @type {Array<FunctionExpression | ArrowFunctionExpression>} */
const computedCallNodes = []
/** @type {Array<FunctionExpression | ArrowFunctionExpression | FunctionDeclaration>} */
const setupFunctions = []
/** @type {[number, number][]} */
const setupRanges = []

/**
* @typedef {object} ScopeStack
Expand All @@ -57,7 +58,114 @@ module.exports = {
scopeStack = scopeStack && scopeStack.upper
}

return Object.assign(
const nodeVisitor = {
':function': onFunctionEnter,
':function:exit': onFunctionExit,

/**
* @param {(Identifier | ThisExpression) & {parent: MemberExpression}} node
* @param {VueObjectData|undefined} [info]
*/
'MemberExpression > :matches(Identifier, ThisExpression)'(node, info) {
if (!scopeStack) {
return
}
const targetBody = scopeStack.body

const computedProperty = (
info ? computedPropertiesMap.get(info.node) || [] : []
).find((cp) => {
return (
cp.value &&
cp.value.range[0] <= node.range[0] &&
node.range[1] <= cp.value.range[1] &&
targetBody === cp.value
)
})
if (computedProperty) {
if (!utils.isThis(node, context)) {
return
}
const mem = node.parent
if (mem.object !== node) {
return
}

const invalid = utils.findMutating(mem)
if (invalid) {
context.report({
node: invalid.node,
message: 'Unexpected side effect in "{{key}}" computed property.',
data: { key: computedProperty.key || 'Unknown' }
})
}
return
}

// ignore `this` for computed functions
if (node.type === 'ThisExpression') {
return
}

const computedFunction = computedCallNodes.find(
(c) =>
c.range[0] <= node.range[0] &&
node.range[1] <= c.range[1] &&
targetBody === c.body
)
if (!computedFunction) {
return
}

const mem = node.parent
if (mem.object !== node) {
return
}

const variable = findVariable(context.getScope(), node)
if (!variable || variable.defs.length !== 1) {
return
}

const def = variable.defs[0]
if (
def.type === 'ImplicitGlobalVariable' ||
def.type === 'TDZ' ||
def.type === 'ImportBinding'
) {
return
}

const isDeclaredInsideSetup = setupRanges.some(
([start, end]) =>
start <= def.node.range[0] && def.node.range[1] <= end
)
if (!isDeclaredInsideSetup) {
return
}

if (
computedFunction.range[0] <= def.node.range[0] &&
def.node.range[1] <= computedFunction.range[1]
) {
// mutating local variables are accepted
return
}

const invalid = utils.findMutating(node)
if (invalid) {
context.report({
node: invalid.node,
message: 'Unexpected side effect in computed function.'
})
}
}
}
const scriptSetupNode = utils.getScriptSetupElement(context)
if (scriptSetupNode) {
setupRanges.push(scriptSetupNode.range)
}
return utils.compositingVisitors(
{
Program() {
const tracker = new ReferenceTracker(context.getScope())
Expand All @@ -80,120 +188,17 @@ module.exports = {
}
}
},
utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
computedPropertiesMap.set(node, utils.getComputedProperties(node))
},
':function': onFunctionEnter,
':function:exit': onFunctionExit,
onSetupFunctionEnter(node) {
setupFunctions.push(node)
},

/**
* @param {(Identifier | ThisExpression) & {parent: MemberExpression}} node
* @param {VueObjectData} data
*/
'MemberExpression > :matches(Identifier, ThisExpression)'(
node,
{ node: vueNode }
) {
if (!scopeStack) {
return
}
const targetBody = scopeStack.body

const computedProperty = /** @type {ComponentComputedProperty[]} */ (
computedPropertiesMap.get(vueNode)
).find((cp) => {
return (
cp.value &&
node.loc.start.line >= cp.value.loc.start.line &&
node.loc.end.line <= cp.value.loc.end.line &&
targetBody === cp.value
)
scriptSetupNode
? utils.defineScriptSetupVisitor(context, nodeVisitor)
: utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
computedPropertiesMap.set(node, utils.getComputedProperties(node))
},
onSetupFunctionEnter(node) {
setupRanges.push(node.body.range)
},
...nodeVisitor
})
if (computedProperty) {
if (!utils.isThis(node, context)) {
return
}
const mem = node.parent
if (mem.object !== node) {
return
}

const invalid = utils.findMutating(mem)
if (invalid) {
context.report({
node: invalid.node,
message:
'Unexpected side effect in "{{key}}" computed property.',
data: { key: computedProperty.key || 'Unknown' }
})
}
return
}

// ignore `this` for computed functions
if (node.type === 'ThisExpression') {
return
}

const computedFunction = computedCallNodes.find(
(c) =>
node.loc.start.line >= c.loc.start.line &&
node.loc.end.line <= c.loc.end.line &&
targetBody === c.body
)
if (!computedFunction) {
return
}

const mem = node.parent
if (mem.object !== node) {
return
}

const variable = findVariable(context.getScope(), node)
if (!variable || variable.defs.length !== 1) {
return
}

const def = variable.defs[0]
if (
def.type === 'ImplicitGlobalVariable' ||
def.type === 'TDZ' ||
def.type === 'ImportBinding'
) {
return
}

const isDeclaredInsideSetup = setupFunctions.some(
(setupFn) =>
def.node.loc.start.line >= setupFn.loc.start.line &&
def.node.loc.end.line <= setupFn.loc.end.line
)
if (!isDeclaredInsideSetup) {
return
}

if (
def.node.loc.start.line >= computedFunction.loc.start.line &&
def.node.loc.end.line <= computedFunction.loc.end.line
) {
// mutating local variables are accepted
return
}

const invalid = utils.findMutating(node)
if (invalid) {
context.report({
node: invalid.node,
message: 'Unexpected side effect in computed function.'
})
}
}
})
)
}
}
6 changes: 6 additions & 0 deletions lib/utils/index.js
Expand Up @@ -908,6 +908,12 @@ module.exports = {
* @param {RuleContext} context The ESLint rule context object.
*/
isScriptSetup,
/**
* Gets the element of `<script setup>`
* @param {RuleContext} context The ESLint rule context object.
* @returns {VElement | null} the element of `<script setup>`
*/
getScriptSetupElement,

/**
* Check if current file is a Vue instance or component and call callback
Expand Down

0 comments on commit 4442509

Please sign in to comment.