webpack: Inconsistency with TypeScript in Module Resolution with Fully-Specified ESM Imports

Bug report

What is the current behavior?

When using extention .js as part of an import (fully-specified ESM import), webpack fails to create a bundle if the file imported have extention .tsx

This shouldnt be like this based on the comment of RyanCavanaugh in this issue https://github.com/microsoft/TypeScript/issues/41887#issuecomment-741968855

“TypeScript doesn’t modify JavaScript code you write, the import path you write should be the one you want to appear in the output .js file”

If the current behavior is a bug, please provide the steps to reproduce.

I created a repo showing this unexpected behavior this repo was tested with both ts-loader and babel-loader

https://github.com/Josehower/webpack-babel-test

  1. clone the repo and install dependencies
  2. use command $yarn start

image

import { test } from './test.js';

console.log('Hello Webpack Project.');
test();
jose fernando hower@DESKTOP-FHTIMFT MINGW64 /D/upleveled-softwork/webpack-test (main)
$ yarn start
yarn run v1.22.5
$ webpack serve --config ./webpack.config.js --mode development
i 「wds」: Project is running at http://localhost:8080/
i 「wds」: webpack output is served from /
i 「wds」: Content not from webpack is served from D:\upleveled-softwork\webpack-test\dist
× 「wdm」: asset bundle.js 367 KiB [emitted] (name: main)
runtime modules 706 bytes 4 modules
cacheable modules 336 KiB
  modules by path ./node_modules/webpack-dev-server/ 21.2 KiB
    modules by path ./node_modules/webpack-dev-server/client/ 20.9 KiB 10 modules
    modules by path ./node_modules/webpack-dev-server/node_modules/ 296 bytes 2 modules
  modules by path ./node_modules/html-entities/lib/*.js 61 KiB 5 modules
  modules by path ./node_modules/querystring/*.js 4.51 KiB 3 modules
  modules by path ./node_modules/webpack/hot/ 1.42 KiB
    ./node_modules/webpack/hot/emitter.js 75 bytes [built] [code generated]
    ./node_modules/webpack/hot/log.js 1.34 KiB [built] [code generated]
  modules by path ./node_modules/url/*.js 23.1 KiB
    ./node_modules/url/url.js 22.8 KiB [built] [code generated]
    ./node_modules/url/util.js 314 bytes [built] [code generated]
./node_modules/webpack/hot/ sync nonrecursive ^\.\/log$ 170 bytes [built] [code generated]

ERROR in ./src/index.tsx 1:0-33
Module not found: Error: Can't resolve './test.js' in 'D:\upleveled-softwork\webpack-test\src'
resolve './test.js' in 'D:\upleveled-softwork\webpack-test\src'
  using description file: D:\upleveled-softwork\webpack-test\package.json (relative path: ./src)
    Field 'browser' doesn't contain a valid alias configuration
    using description file: D:\upleveled-softwork\webpack-test\package.json (relative path: ./src/test.js)
      no extension
        Field 'browser' doesn't contain a valid alias configuration
        D:\upleveled-softwork\webpack-test\src\test.js doesn't exist
      *
        Field 'browser' doesn't contain a valid alias configuration
        D:\upleveled-softwork\webpack-test\src\test.js* doesn't exist
      .js
        Field 'browser' doesn't contain a valid alias configuration
        D:\upleveled-softwork\webpack-test\src\test.js.js doesn't exist
      .ts
        Field 'browser' doesn't contain a valid alias configuration
        D:\upleveled-softwork\webpack-test\src\test.js.ts doesn't exist
      .tsx
        Field 'browser' doesn't contain a valid alias configuration
        D:\upleveled-softwork\webpack-test\src\test.js.tsx doesn't exist
      as directory
        D:\upleveled-softwork\webpack-test\src\test.js doesn't exist

webpack 5.36.0 compiled with 1 error in 2410 ms
i 「wdm」: Failed to compile.
  1. use command $yarn tsc --noEmit
jose fernando hower@DESKTOP-FHTIMFT MINGW64 /D/upleveled-softwork/webpack-test (main)
$ yarn tsc --noEmit
yarn run v1.22.5
$ D:\upleveled-softwork\webpack-test\node_modules\.bin\tsc --noEmit
✨  Done in 1.49s.

image

What is the expected behavior?

  • In the example repo yarn start should succesfully create a bundle.

  • Webpack should be able to recognize the files imported and create a bundle even if the import statements with the extention .js point files that have the extention .tsx

Other relevant information:

OS: Windows 10 10.0.19041
CPU: (4) x64 Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
Binaries:
Node: 16.0.0 - ~\AppData\Local\Temp\yarn--1619542324765-0.4257021548777624\node.CMD
Yarn: 1.22.5 - ~\AppData\Local\Temp\yarn--1619542324765-0.4257021548777624\yarn.CMD
npm: 7.10.0 - C:\Program Files\nodejs\npm.CMD
Browsers:
Chrome: 90.0.4430.93
Edge: Spartan (44.19041.906.0), Chromium (90.0.818.46)

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 10
  • Comments: 53 (49 by maintainers)

Commits related to this issue

Most upvoted comments

another workaround I found was using NormalModuleReplacementPlugin to replace the problematic extension

 module.exports = function (env) {
  return {
    plugins: [
      new webpack.NormalModuleReplacementPlugin(new RegExp(/^\..+\.js$/), function (resource) {
        resource.request = resource.request.replace(new RegExp(/\.js$/), '');
      }),
        }
      ),
    ],
  };
};

in our case, this did the trick

Updated with the RegExp suggested by @laverdet https://github.com/webpack/webpack/issues/13252#issuecomment-1185674918

another workaround i found was using NormalModuleReplacementPlugin to replace the problematic extention

 module.exports = function (env) {
  return {
    plugins: [
      new webpack.NormalModuleReplacementPlugin(new RegExp(/\.js$/), function (resource) {
        resource.request = resource.request.replace('.js', '');
      }),
        }
      ),
    ],
  };
};

in our case this did the trick

I tweaked the RegExp here because otherwise imports to modules like sha.js will fail. Thanks!

            new NormalModuleReplacementPlugin(/^\..+\.js$/, resource => {
                resource.request = resource.request.replace(/\.js$/, "");
            }),

I think we can close it, now we have extensionAlias - https://webpack.js.org/configuration/resolve/#resolveextensionalias

Something like that could be solved in a custom resolver plugin. I think think this typescript-specific problem fits into the webpack core.

The resolver plugin could take an extensions argument which is a list of typescript extensions that should be tried when the request ends with .js.

Use extensionAlias in Next.js via experimental.extensionAlias config introduced in https://github.com/vercel/next.js/pull/45423:

next.config.js

/** @type {import("next").NextConfig} */
const nextConfig = {
  experimental: {
    // Remove .js from import specifiers, because
    // Next.js and webpack do not yet support
    // TypeScript-style module resolution out of the box
    // https://github.com/webpack/webpack/issues/13252#issuecomment-1171080020
    // https://github.com/vercel/next.js/pull/45423
    // https://github.com/vercel/next.js/issues/58805
    // https://github.com/vercel/next.js/issues/54550
    extensionAlias: {
      '.js': ['.ts', '.tsx', '.js', '.jsx'],
      '.jsx': ['.tsx', '.jsx'],
      '.mjs': ['.mts', '.mjs'],
      '.cjs': ['.cts', '.cjs'],
    },
  },
}

module.exports = nextConfig

Yeah, sometimes I think npm should not allow to use . in a package name, because it is pain to write regexp and code looks misleading:

var shajs = require('sha.js')

Do we load ./sha.js or ./sha.js/index.js? weird

not stale

The problem is that using the fully-specified .js extension fails when using TypeScript .ts files (they are not resolved in parity with the behavior of TypeScript and tsc)

With TypeScript and tsc, the following works:

// index.ts
import {abc} from './abc.js';
// abc.ts
export const abc = 1;

With webpack, it fails and cannot resolve the ./abc.js specifier (note the difference between the extensions - .js and .ts).

See @RyanCavanaugh’s comment here: https://github.com/microsoft/TypeScript/issues/41887#issuecomment-741968855

TypeScript doesn’t modify JavaScript code you write, the import path you write should be the one you want to appear in the output .js file

Oh sorry, deleted my comment now! Indeed the type is there in 5.74.0, somehow one of my webpack versions was locked to 5.73.0 😬

@iamWing the /^\..+\.js$/ regex seems to work for ./app.js, and it is intended to filter out sha.js:

> /^\..+\.js$/.test('./app.js')
> true

another workaround i found was using NormalModuleReplacementPlugin to replace the problematic extention

 module.exports = function (env) {
  return {
    plugins: [
      new webpack.NormalModuleReplacementPlugin(new RegExp(/\.js$/), function (resource) {
        resource.request = resource.request.replace('.js', '');
      }),
        }
      ),
    ],
  };
};

in our case this did the trick

I tweaked the RegExp here because otherwise imports to modules like sha.js will fail. Thanks!

            new NormalModuleReplacementPlugin(/^\..+\.js$/, resource => {
                resource.request = resource.request.replace(/\.js$/, "");
            }),

The regex on the tweaked version doesn’t seem right to me. Neither _/app.js or sha.js matches the expression.

My suggestion would be use /.*\/+.+\.js$/ instead. Matching a / character in the middle of the path can help identifying if we’re importing a local module or not, because if we’re importing a dependency installed we usually just write import something from 'something';, but if we’re importing a local module, we write import something from './path/something.js'.

A better way to cover all cases would be tweaking the function in NormalModuleReplacementPlugin to exclude some patterns for the edge cases.

e.g.

new NormalModuleReplacementPlugin(/.*\/+.+\.js$/, (resource) => {
  if (/^sha\.js$/.test(resource.request) return; // - Exclude the edge cases using if statement
  resource.request = resource.request.replace(/\.js$/, '');
}),

Maybe we can also develop a plugin to do what we’re doing with NormalModuleReplacementPlugin here but also check the dependency list from package.json so that all packages listed there can be excluded automatically? I’ll do some research on this later.

Edited for typo

technically we can add this to default rules but webpack doesn’t support ts out of the box… so probably you will need specify extension alias

Accidentally close, yes, you can specify extensionAlias in the resolve option (https://webpack.js.org/configuration/resolve/), like you do it for alias/etc

just one line

Ok, so what is the configuration that would be required? I’m sure that this will be a common question for those looking to take advantage of this.

What I can see from the PR is something like this:

{
  "extensionAlias": {
    ".js": [".ts", ".js"],
    ".mjs": ".mts"
  }
}

Is extensionAlias a valid top-level key in webpack config? Or does enhanced-resolve need to be added via a plugin or something in the config as well?

@karlhorky It is not implemented in enhanced-resolve (we should start with it), but it is easy https://github.com/TypeStrong/ts-loader/issues/1383#issuecomment-968075478, just want to verify @sokra and @vankop are supporting my idea

@karlhorky I think we need a plugin in https://github.com/webpack/enhanced-resolve/ (I already implemented it in thread) and allow customize resolver, allow ts-loader can create custom resolver using loader API and supports it out of box

Simple workaround for your simple case:

resolve: {
    alias: {
      './test.js': './test'
    },
    extensions: ['.tsx', '.ts', '...'],
},

Ideally ts-loader should respect resolve.extensions (better resolve.byDependency.typescript) and when you using import something from './foo.js' try to resolve ./foo.js as ./foo.tsx/./foo.ts and then other variants (i.e. ./foo.js and etc).

To avoid collisions between ts-loader resolving and Node.js resolving we should support https://webpack.js.org/configuration/resolve/#resolvebydependency, so you will use:

resolve: {
  byDependency: {
    typescript: {
      extensions: ['.tsx', '.ts', '...'],
    },
}    

It will be implement using https://webpack.js.org/api/loaders/#thisgetresolve, we use the same logic for sass/less/etc so @import with CSS and SASS works good.

Honestly it’s not hard to do.

Yep, I see…

[tsl] ERROR in /home/path/to/webpack-babel-test/src/index.tsx(1,22)
      TS2691: An import path cannot end with a '.tsx' extension. Consider importing './test.js' instead.

We need improve it on ts-loader side, webpack works correctly here.

I think in ts better to have ts extension at the end, it is fully-specified import

The TypeScript team doesn’t agree, check out the issue I linked above: https://github.com/microsoft/TypeScript/issues/41887#issuecomment-741968855

What is problem with webpack here? If you use import you need to write extension, or disable fully-specified ESM import, don’t know what we should fix here…