next.js icon indicating copy to clipboard operation
next.js copied to clipboard

Module not found: Fully Specified ESM Imports (with `.js` extension) in TypeScript

Open karlhorky opened this issue 3 years ago • 7 comments

Verify canary release

  • [X] I verified that the issue exists in the latest Next.js canary release

Provide environment information

 
    Operating System:
      Platform: darwin
      Arch: arm64
      Version: Darwin Kernel Version 22.1.0: Sun Oct  9 20:14:30 PDT 2022; root:xnu-8792.41.9~2/RELEASE_ARM64_T8103
    Binaries:
      Node: 18.11.0
      npm: 8.19.2
      Yarn: 1.22.19
      pnpm: 3.8.1
    Relevant packages:
      next: 13.0.0
      eslint-config-next: N/A
      react: 18.2.0
      react-dom: 18.2.0

What browser are you using? (if relevant)

No response

How are you deploying your application? (if relevant)

No response

Describe the Bug

Hi there, first of all, thanks so much for the hard work on Next.js 🙌 13 is looking great!

Importing a file using the fully-specified ESM-style imports with .js leads to a Module not found error:

pages/index.tsx

import Component from "../components/Component.js";

/** Add your relevant code here for the issue to reproduce */
export default function Home() {
  return <Component />
}

components/Component.tsx:

export default function Component() {
  return <div>asdf</div>;
}

Error message:

wait  - compiling /_error (client and server)...
error - ./pages/index.tsx:1:0
Module not found: Can't resolve '../components/Component.js'
> 1 | import Component from "../components/Component.js";
  2 | 
  3 | /** Add your relevant code here for the issue to reproduce */
  4 | export default function Home() {

This does not match the behavior of the TypeScript compiler, which will resolve these files. See https://github.com/microsoft/TypeScript/issues/41887#issuecomment-741968855 for more details.

experimental.fullySpecified config option

This also happens while using the experimental.fullySpecified: true option in the Next.js config:

next.config.mjs

/** @type {import('next').NextConfig} */
const config = {
  experimental: {
    fullySpecified: true,
  },
  reactStrictMode: true,
}
export default config;

Workaround 1: resolve.extensionAlias configuration option

webpack does have a resolve.extensionAlias configuration as of 5.74.0:

/** @type {import('next').NextConfig} */
const config = {
  reactStrictMode: true,
  webpack: (
    webpackConfig,
    { webpack },
  ) => {
    webpackConfig.resolve.extensionAlias = {
      '.js': ['.ts', '.tsx', '.js', '.jsx'],
      '.mjs': ['.mts', '.mjs'],
      '.cjs': ['.cts', '.cjs'],
    };
    return webpackConfig;
  },
};

export default config;

But this should be zero-config.

Related issue in webpack: https://github.com/webpack/webpack/issues/13252

Workaround 2: webpack.NormalModuleReplacementPlugin configuration option

Configure the NormalModuleReplacementPlugin inside your webpack config

next.config.mjs

/** @type {import('next').NextConfig} */
const config = {
  reactStrictMode: true,
  webpack: (
    webpackConfig,
    { webpack },
  ) => {
    webpackConfig.plugins.push(
      new webpack.NormalModuleReplacementPlugin(new RegExp(/\.js$/), function (
        /** @type {{ request: string }} */
        resource,
      ) {
        resource.request = resource.request.replace('.js', '');
      }),
    );
    return webpackConfig;
  },
};

export default config;

Also, this should be zero-config.

Expected Behavior

Fully-specified ESM-style imports with .js extensions should resolve to the respective TypeScript files out of the box (as TSC does it), without any further webpack / Turbopack configuration necessary.

Link to reproduction

https://stackblitz.com/edit/vercel-next-js-y6faeb?file=components%2FComponent.tsx,pages%2Findex.tsx,next.config.js

To Reproduce

  1. Use TypeScript
  2. Import another TypeScript / TSX file with fully-specified .js
  3. Observe error message

karlhorky avatar Oct 27 '22 11:10 karlhorky

Workaround 2 works, but it has a typo.

  webpackConfig.resolve.extensionAlias = {
    '.js': ['.ts', '.tsx', '.js'],
-   '.mjs': ['.mts', '.js'],
+   '.mjs': ['.mts', '.mjs'],
    '.cjs': ['.cts', '.cjs'],
  };

I think .jsx should be supported too.

webpackConfig.resolve.extensionAlias = {
  '.js': ['.ts', '.tsx', '.jsx', '.js'],
  '.mjs': ['.mts', '.mjs'],
  '.cjs': ['.cts', '.cjs'],
};

remcohaszing avatar Nov 01 '22 20:11 remcohaszing

Edited, thanks. I did mention that Workaround 2 worked also in the description above. It would still be nice if this was zero-config for people though 👍

karlhorky avatar Nov 01 '22 21:11 karlhorky

Edited, thanks. I did mention that Workaround 2 worked also in the description above.

Yes, but I believe this fixes the error you mentioned. :)

It would still be nice if this was zero-config for people though :+1:

Definitely!

remcohaszing avatar Nov 03 '22 10:11 remcohaszing

Yes, but I believe this fixes the error you mentioned. :)

Indeed, missed this - it is working! I've updated my description above.

karlhorky avatar Nov 03 '22 11:11 karlhorky

With this next.config.mjs:

/** @type {import('next').NextConfig} */
export default {
  webpack: (webpackConfig, { webpack }) => {
    webpackConfig.resolve.extensionAlias = {
      ".js": [".ts", ".tsx", ".js", ".jsx"],
      ".mjs": [".mts", ".mjs"],
      ".cjs": [".cts", ".cjs"],
    };
    return webpackConfig;
  },
};

You run into a build error if you deep import .mjs modules from dependencies in your Next.js project like this:

import withGraphQLReact from "next-graphql-react/withGraphQLReact.mjs";

> dev
> next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info  - Loaded env from [redacted]/.env
error - ./pages/_app.tsx:3:0
Module not found: Package path ./withGraphQLReact.mts is not exported from package [redacted]/node_modules/next-graphql-react (see exports field in [redacted]/node_modules/next-graphql-react/package.json)
  1 | import type { AppProps } from "next/app.js";
  2 | import nextHead from "next/head.js";
> 3 | import withGraphQLReact from "next-graphql-react/withGraphQLReact.mjs";
  4 | 
  5 | const { default: Head } = nextHead;
  6 | 

https://nextjs.org/docs/messages/module-not-found

Can the workaround be tweaked somehow to account for this?

jaydenseric avatar Nov 10 '22 05:11 jaydenseric

Not sure exactly why that's happening, but you could try reporting that over in https://github.com/webpack/webpack/issues/13252, since that's the related issue in the webpack repo.

If you find a solution, let us know and I'll update my post above.

Or you could try Workaround 2, which uses webpack.NormalModuleReplacementPlugin instead.

karlhorky avatar Nov 10 '22 08:11 karlhorky

This workaround appears to work:

// @ts-check

import { existsSync } from "node:fs";
import { join, parse, resolve } from "node:path";

/** @type {import("next").NextConfig} */
export default {
  typescript: {
    // TODO: Investigate why TypeScript via Next.js builds has ESM/CJS default
    // import interop errors when vanilla TypeScript via VS Code and the package
    // script `type-check` is ok.
    ignoreBuildErrors: true,
  },
  webpack: (webpackConfig, { webpack }) => {
    // TODO: Remove this config once this Next.js issue that `.tsx` files can’t
    // be imported using the `.js` file extension is fixed:
    // https://github.com/vercel/next.js/issues/41961
    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
    webpackConfig.plugins.push(
      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
      new webpack.NormalModuleReplacementPlugin(/\.js$/, function (
        /** @type {{ context: string, request: string }} */
        resource
      ) {
        // Skip a non relative import (e.g. a bare import specifier).
        if (resource.request.startsWith(".")) {
          const path = resolve(resource.context, resource.request);

          if (
            // Skip the relative import if it reaches into `node_modules`.
            !path.includes("node_modules") &&
            !existsSync(path)
          ) {
            const { dir, name } = parse(path);
            const extensionlessPath = join(dir, name);

            for (const fallbackExtension of [".tsx", ".ts", ".js"]) {
              if (existsSync(extensionlessPath + fallbackExtension)) {
                resource.request = resource.request.replace(
                  /\.js$/,
                  fallbackExtension
                );
                break;
              }
            }
          }
        }
      })
    );

    // eslint-disable-next-line @typescript-eslint/no-unsafe-return
    return webpackConfig;
  },
};

jaydenseric avatar Nov 11 '22 00:11 jaydenseric

This closed issue has been automatically locked because it had no new activity for a month. If you are running into a similar issue, please create a new issue with the steps to reproduce. Thank you.

github-actions[bot] avatar Feb 28 '23 00:02 github-actions[bot]