From d9036f67ec31c667e30456d19a6794d033c83f04 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:32:02 +0200 Subject: [PATCH] fix(typecheck): warn about multiple Vue versions --- packages/nuxt-cli/src/commands/typecheck.ts | 70 ++++++++++++++++++- .../test/unit/commands/typecheck.spec.ts | 44 +++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 751805dcd..e3da98390 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -1,6 +1,6 @@ import type { TSConfig } from 'pkg-types' import { existsSync, readFileSync } from 'node:fs' -import { writeFile } from 'node:fs/promises' +import { readdir, readFile, realpath, writeFile } from 'node:fs/promises' import process from 'node:process' import { styleText } from 'node:util' @@ -167,6 +167,10 @@ export default defineCommand({ } return } + const vueVersions = await findVueVersions(cwd) + if (vueVersions.length > 1) { + logger.warn(`Multiple versions of ${styleText('cyan', 'vue')} are installed (${vueVersions.join(', ')}). This can break template type augmentations. Dedupe your dependencies or pin a single Vue version before investigating type errors.`) + } if (hasTTY) { logger.error(`Type check failed in ${styleText('cyan', duration)}.`) @@ -175,6 +179,70 @@ export default defineCommand({ }, }) +async function findVueVersions(cwd: string): Promise { + const versions = new Set() + const pending: string[] = [] + const visited = new Set() + + for (let directory = cwd; ; directory = dirname(directory)) { + pending.push(resolve(directory, 'node_modules')) + const parent = dirname(directory) + if (parent === directory) { + break + } + } + pending.reverse() + + while (pending.length > 0 && versions.size < 2) { + const nodeModules = pending.pop()! + const resolvedNodeModules = await realpath(nodeModules).catch(() => undefined) + if (!resolvedNodeModules || visited.has(resolvedNodeModules)) { + continue + } + visited.add(resolvedNodeModules) + + const resolvedVuePath = await realpath(resolve(resolvedNodeModules, 'vue')).catch(() => undefined) + if (resolvedVuePath) { + try { + const manifest = JSON.parse(await readFile(resolve(resolvedVuePath, 'package.json'), 'utf8')) as { version?: unknown } + if (typeof manifest.version === 'string') { + versions.add(manifest.version) + } + } + catch { + // Ignore incomplete package installations. + } + } + + const entries = await readdir(resolvedNodeModules, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (entry.name === '.bin' || entry.name === 'vue') { + continue + } + const packagePath = resolve(resolvedNodeModules, entry.name) + if (entry.name.startsWith('@')) { + const scopedPackages = await readdir(packagePath).catch(() => []) + for (const packageName of scopedPackages) { + pending.push(resolve(packagePath, packageName, 'node_modules')) + } + } + else if (entry.name === '.pnpm') { + const packages = await readdir(packagePath).catch(() => []) + for (const packageName of packages) { + if (packageName.startsWith('vue@')) { + pending.push(resolve(packagePath, packageName, 'node_modules')) + } + } + } + else { + pending.push(resolve(packagePath, 'node_modules')) + } + } + } + + return [...versions].sort() +} + const NUXT_PROJECT_REFERENCE_RE = /(?:^|[/\\])tsconfig\.(?:app|server|shared|node)\.json$/ /** diff --git a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts index cfbcc2402..6e9d64532 100644 --- a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts @@ -1,6 +1,9 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runCommand } from '../../../src/run' import { logger } from '../../../src/utils/logger' @@ -31,6 +34,21 @@ function fixture(name: string) { return fileURLToPath(new URL(`../../fixtures/typecheck/${name}`, import.meta.url)) } +const tempDirs: string[] = [] + +async function vueProject(...versions: string[]) { + const cwd = await mkdtemp(join(tmpdir(), 'nuxt-typecheck-')) + tempDirs.push(cwd) + for (const [index, version] of versions.entries()) { + const vueDir = index === 0 + ? join(cwd, 'node_modules/vue') + : join(cwd, `node_modules/dependency-${index}/node_modules/vue`) + await mkdir(vueDir, { recursive: true }) + await writeFile(join(vueDir, 'package.json'), JSON.stringify({ name: 'vue', version })) + } + return cwd +} + async function run(cwd: string, ...args: string[]) { await runCommand('typecheck', ['--cwd', cwd, ...args]) return x.mock.calls[0]?.[1] @@ -44,6 +62,10 @@ describe('nuxt typecheck command', () => { x.mockResolvedValue({ exitCode: 0 }) }) + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) + }) + it('should use build mode for Nuxt project references', async () => { expect(await run(fixture('nuxt-references'))).toEqual(['-b', '--noEmit']) }) @@ -67,6 +89,26 @@ describe('nuxt typecheck command', () => { warn.mockRestore() }) + it('should warn when multiple Vue versions are installed', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + x.mockResolvedValueOnce({ exitCode: 2 }) + + await run(await vueProject('3.5.40', '3.5.41')) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('3.5.40, 3.5.41')) + warn.mockRestore() + }) + + it('should not warn for duplicate copies of the same Vue version', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + x.mockResolvedValueOnce({ exitCode: 2 }) + + await run(await vueProject('3.5.41', '3.5.41')) + + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + it('should not prepare Nuxt when the requested checker is unavailable', async () => { resolveModulePath.mockReturnValue(undefined)