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,