diff --git a/packages/playground/resolve/__tests__/resolve.spec.ts b/packages/playground/resolve/__tests__/resolve.spec.ts index b94be689371b22..b1524e1e42aa08 100644 --- a/packages/playground/resolve/__tests__/resolve.spec.ts +++ b/packages/playground/resolve/__tests__/resolve.spec.ts @@ -54,6 +54,10 @@ test('dont add extension to directory name (./dir-with-ext.js/index.js)', async expect(await page.textContent('.dir-with-ext')).toMatch('[success]') }) +test('a ts module can import another ts module using its corresponding js file name', async () => { + expect(await page.textContent('.ts-extension')).toMatch('[success]') +}) + test('filename with dot', async () => { expect(await page.textContent('.dot')).toMatch('[success]') }) diff --git a/packages/playground/resolve/index.html b/packages/playground/resolve/index.html index a121c5c8a68ca1..9dc6525fcd7a43 100644 --- a/packages/playground/resolve/index.html +++ b/packages/playground/resolve/index.html @@ -33,6 +33,11 @@

Resolve to non-duplicated file extension

Don't add extensions to directory names

fail

+

+ A ts module can import another ts module using its corresponding js file name +

+

fail

+

Resolve file name containing dot

fail

@@ -119,6 +124,9 @@

resolve package that contains # in path

import { file as dirWithExtMsg } from './dir-with-ext' text('.dir-with-ext', dirWithExtMsg) + import { msg as tsExtensionMsg } from './ts-extension' + text('.ts-extension', tsExtensionMsg) + // filename with dot import { bar } from './util/bar.util' text('.dot', bar()) diff --git a/packages/playground/resolve/ts-extension/hello.ts b/packages/playground/resolve/ts-extension/hello.ts new file mode 100644 index 00000000000000..0189355c3fe06f --- /dev/null +++ b/packages/playground/resolve/ts-extension/hello.ts @@ -0,0 +1 @@ +export const msg = '[success] use .js extension to import a ts module' diff --git a/packages/playground/resolve/ts-extension/index.ts b/packages/playground/resolve/ts-extension/index.ts new file mode 100644 index 00000000000000..e095619ee4d716 --- /dev/null +++ b/packages/playground/resolve/ts-extension/index.ts @@ -0,0 +1,3 @@ +import { msg } from './hello.js' + +export { msg } diff --git a/packages/playground/resolve/vite.config.js b/packages/playground/resolve/vite.config.js index a05d51bb5b8e45..e7d531097add7c 100644 --- a/packages/playground/resolve/vite.config.js +++ b/packages/playground/resolve/vite.config.js @@ -2,7 +2,7 @@ const virtualFile = '@virtual-file' module.exports = { resolve: { - extensions: ['.mjs', '.js', '.es'], + extensions: ['.mjs', '.js', '.es', '.ts'], mainFields: ['custom', 'module'], conditions: ['custom'] }, diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 5b6fd6c59e9f72..49ffcec3bd33e5 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -25,7 +25,10 @@ import { cleanUrl, slash, nestedResolveFrom, - isFileReadable + isFileReadable, + isTsRequest, + isPossibleTsOutput, + getTsSrcPath } from '../utils' import { ViteDevServer, SSROptions } from '..' import { createFilter } from '@rollup/pluginutils' @@ -65,6 +68,11 @@ export interface InternalResolveOptions extends ResolveOptions { skipPackageJson?: boolean preferRelative?: boolean isRequire?: boolean + // #3040 + // when the importer is a ts module, + // if the specifier requests a non-existent `.js/jsx/mjs/cjs` file, + // should also try import from `.ts/tsx/mts/cts` source file as fallback. + isFromTsImporter?: boolean } export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { @@ -75,10 +83,6 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { ssrConfig, preferRelative = false } = baseOptions - const requireOptions: InternalResolveOptions = { - ...baseOptions, - isRequire: true - } let server: ViteDevServer | undefined const { target: ssrTarget, noExternal: ssrNoExternal } = ssrConfig ?? {} @@ -104,13 +108,15 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { const targetWeb = !ssr || ssrTarget === 'webworker' // this is passed by @rollup/plugin-commonjs - const isRequire = - resolveOpts && - resolveOpts.custom && - resolveOpts.custom['node-resolve'] && - resolveOpts.custom['node-resolve'].isRequire + const isRequire: boolean = + resolveOpts?.custom?.['node-resolve']?.isRequire ?? false + + const options: InternalResolveOptions = { + ...baseOptions, - const options = isRequire ? requireOptions : baseOptions + isRequire, + isFromTsImporter: isTsRequest(importer ?? '') + } const preserveSymlinks = !!server?.config.resolve.preserveSymlinks @@ -450,6 +456,22 @@ function tryResolveFile( if (index) return index + postfix } } + + const tryTsExtension = options.isFromTsImporter && isPossibleTsOutput(file) + if (tryTsExtension) { + const tsSrcPath = getTsSrcPath(file) + return tryResolveFile( + tsSrcPath, + postfix, + options, + tryIndex, + targetWeb, + preserveSymlinks, + tryPrefix, + skipPackageJson + ) + } + if (tryPrefix) { const prefixed = `${path.dirname(file)}/${tryPrefix}${path.basename(file)}` return tryResolveFile( diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 4d6d4436b4248b..bdbbce4faa166e 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -164,6 +164,14 @@ export const isJSRequest = (url: string): boolean => { return false } +const knownTsRE = /\.(ts|mts|cts|tsx)$/ +const knownTsOutputRE = /\.(js|mjs|cjs|jsx)$/ +export const isTsRequest = (url: string) => knownTsRE.test(cleanUrl(url)) +export const isPossibleTsOutput = (url: string) => + knownTsOutputRE.test(cleanUrl(url)) +export const getTsSrcPath = (filename: string) => + filename.replace(/\.([cm])?(js)(x?)$/, '.$1ts$3') + const importQueryRE = /(\?|&)import=?(?:&|$)/ const internalPrefixes = [ FS_PREFIX,