Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/whole-readers-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-vue': patch
---

Ignore members imported from elsewhere in `vue/one-component-per-file`
6 changes: 3 additions & 3 deletions lib/rules/one-component-per-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ module.exports = {
fixable: null,
schema: [],
messages: {
toManyComponents: 'There is more than one component in this file.'
tooManyComponents: 'There is more than one component in this file.'
}
},
/** @param {RuleContext} context */
Expand All @@ -29,7 +29,7 @@ module.exports = {
{},
utils.executeOnVueComponent(context, (node, type) => {
if (type === 'definition') {
const defType = getVueComponentDefinitionType(node)
const defType = getVueComponentDefinitionType(context, node)
if (defType === 'mixin') {
return
}
Expand All @@ -42,7 +42,7 @@ module.exports = {
for (const node of components) {
context.report({
node,
messageId: 'toManyComponents'
messageId: 'tooManyComponents'
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/rules/require-expose.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ module.exports = {
return
}
if (type === 'definition') {
const defType = getVueComponentDefinitionType(component)
const defType = getVueComponentDefinitionType(context, component)
if (defType === 'mixin') {
return
}
Expand Down
2 changes: 1 addition & 1 deletion lib/rules/require-name-property.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ module.exports = {
}
return utils.executeOnVue(context, (component, type) => {
if (type === 'definition') {
const defType = getVueComponentDefinitionType(component)
const defType = getVueComponentDefinitionType(context, component)
if (defType === 'mixin') {
return
}
Expand Down
111 changes: 98 additions & 13 deletions lib/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2186,11 +2186,7 @@ module.exports = {
* Wraps composition API trace map in both 'vue' and '@vue/composition-api' imports, or '#imports' from unimport
* @param {import('@eslint-community/eslint-utils').TYPES.TraceMap} map
*/
createCompositionApiTraceMap: (map) => ({
vue: map,
'@vue/composition-api': map,
'#imports': map
}),
createCompositionApiTraceMap,

/**
* Iterates all references in the given trace map.
Expand Down Expand Up @@ -2727,13 +2723,85 @@ function isVueComponentFile(node, path) {
)
}

/**
* Whether the given callee refers to a symbol exported by Vue.
* @param {Identifier} callee Node to check.
* @param {RuleContext} context The rule context to use parser services.
* @returns {boolean}
*/
function isGlobalOrImportedFromVue(callee, context) {
const globalScope = context.sourceCode.scopeManager.globalScope

if (globalScope === null) {
return true
}

const tracker = new ReferenceTracker(globalScope)

const globalTraceMap = {
[callee.name]: {
[ReferenceTracker.CALL]: true
}
}
const cjsTraceMap = createCompositionApiTraceMap(globalTraceMap)
const esmTraceMap = createCompositionApiTraceMap({
[ReferenceTracker.ESM]: true,
[callee.name]: {
[ReferenceTracker.CALL]: true
}
})

function* allReferences() {
yield* tracker.iterateGlobalReferences(globalTraceMap)
yield* tracker.iterateEsmReferences(esmTraceMap)
yield* tracker.iterateCjsReferences(cjsTraceMap)
}

for (const { node } of allReferences()) {
if (node === callee.parent) {
return true
}
}

// No references found
// The callee is either from a different source, or it is injected
// (e.g., <script>defineComponent({})</script>).

const variable = findVariable(getScope(context, callee), callee)

// console.log('foobarr', context.sourceCode.text, variable?.defs[0])

if (variable !== null && variable.defs.length === 1) {
const def = variable.defs[0]

if (
def.type === 'ImportBinding' &&
def.node.type === 'ImportSpecifier' &&
def.node.imported.type === 'Identifier' &&
def.node.parent.type === 'ImportDeclaration' &&
def.node.parent.source.value !== 'vue' &&
def.node.parent.source.value !== '@vue/composition-api'
) {
return false
}

// ignore `function name() {}`
if (def.type === 'FunctionName') {
return false
}
}

return true
}

/**
* Get the Vue component definition type from given node
* Vue.component('xxx', {}) || component('xxx', {})
* @param {RuleContext} context The rule context to use parser services.
* @param {ObjectExpression} node Node to check
* @returns {'component' | 'mixin' | 'extend' | 'createApp' | 'defineComponent' | 'defineNuxtComponent' | null}
*/
function getVueComponentDefinitionType(node) {
function getVueComponentDefinitionType(context, node) {
const parent = getParent(node)
if (parent.type === 'CallExpression') {
const callee = parent.callee
Expand Down Expand Up @@ -2769,6 +2837,17 @@ function getVueComponentDefinitionType(node) {
}

if (callee.type === 'Identifier') {
if (callee.name === 'defineNuxtComponent') {
// for Nuxt 3.x
// defineNuxtComponent({})
const isDestructedVueComponent = isObjectArgument(parent)
return isDestructedVueComponent ? 'defineNuxtComponent' : null
}

if (!isGlobalOrImportedFromVue(callee, context)) {
Copy link
Copy Markdown
Member

@FloEdelmann FloEdelmann Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it makes sense to split this function into isGlobal and isImportedFromVue? Then the Nuxt case could be moved down again and benefit from the same treatment (as currently, creating a user function called defineNuxtComponent would trigger the same false-positive, right?)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several test cases that import defineNuxtComponent from #app. It seems to be related to @unjs/unimport. I don't know Nuxt, so I can't be sure whether this treatment can be applied equally well to defineNuxtComponent.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for splitting, I think it's easier to refactor isGlobalOrImportedFromVue(callee, context) into isGlobalOrImportedFrom(callee, 'vue' | 'nuxt', context) than isGlobal(callee, context) || isImportedFrom(calee, 'vue' | 'nuxt', context).

return null
}

if (callee.name === 'component') {
// for Vue.js 2.x
// component('xxx', {})
Expand All @@ -2787,12 +2866,6 @@ function getVueComponentDefinitionType(node) {
const isDestructedVueComponent = isObjectArgument(parent)
return isDestructedVueComponent ? 'defineComponent' : null
}
if (callee.name === 'defineNuxtComponent') {
// for Nuxt 3.x
// defineNuxtComponent({})
const isDestructedVueComponent = isObjectArgument(parent)
return isDestructedVueComponent ? 'defineNuxtComponent' : null
}
}
}

Expand Down Expand Up @@ -2860,7 +2933,7 @@ function getVueObjectType(context, node) {
case 'CallExpression': {
// Vue.component('xxx', {}) || component('xxx', {})
if (
getVueComponentDefinitionType(node) != null &&
getVueComponentDefinitionType(context, node) != null &&
skipTSAsExpression(parent.arguments.at(-1)) === node
) {
return 'definition'
Expand Down Expand Up @@ -3010,6 +3083,18 @@ function* iterateWatchHandlerValues(property) {
}
}

/**
* Wraps composition API trace map in both 'vue' and '@vue/composition-api' imports, or '#imports' from unimport
* @param {import('@eslint-community/eslint-utils').TYPES.TraceMap} map
*/
function createCompositionApiTraceMap(map) {
return {
vue: map,
'@vue/composition-api': map,
'#imports': map
}
}

/**
* Get the attribute which has the given name.
* @param {VElement} node The start tag node to check.
Expand Down
134 changes: 134 additions & 0 deletions tests/lib/rules/one-component-per-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,45 @@ ruleTester.run('one-component-per-file', rule, {
Vue.mixin({})
Vue.component('name', {})
`
},
{
filename: 'test.js',
code: `
import { createApp } from 'vue'
createApp({})
`
},
{
filename: 'test.js',
code: `
import { component } from 'other.js'
component({})
component({})
`
},
{
filename: 'test.js',
code: `
import { createApp } from 'other.js'
createApp({})
createApp({})
`
},
{
filename: 'test.js',
code: `
import { defineComponent } from 'other.js'
defineComponent({})
defineComponent({})
`
},
{
filename: 'test.js',
code: `
function createApp() {}
createApp({})
createApp({})
`
}
],
invalid: [
Expand Down Expand Up @@ -93,6 +132,101 @@ ruleTester.run('one-component-per-file', rule, {
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
import { component } from 'vue'
component('', {})
component('', {})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
import { component } from '@vue/composition-api'
component('', {})
component('', {})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
import { createApp } from 'vue'
createApp({})
createApp({})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
import { createApp } from '@vue/composition-api'
createApp({})
createApp({})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
import { defineComponent } from 'vue'
defineComponent({})
defineComponent({})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
import { defineComponent } from '@vue/composition-api'
defineComponent('', {})
defineComponent('', {})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
defineComponent({})
defineComponent({})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
},
{
filename: 'test.vue',
code: `
const { defineComponent } = require('vue')
defineComponent({})
defineComponent({})
`,
errors: [
'There is more than one component in this file.',
'There is more than one component in this file.'
]
}
]
})
Loading