Getting empty object on importing react component from library even after adding output.library in webpack config

752 Views Asked by At

I'm writing a sample typescript react component library with folder structure as shown below, exporting just 1 button component. On running npm run build, just one js bundle is generated. The bundle has the react library code (did not use externals property yet in webpack config) but not the exported button component with the button tag (on searching keyword button in bundle file, was getting no results). Have added the code of bundle/build file also.

Not sure where I'm going wrong.

This issue does not happen with webpack bundles while exporting simple javascript function i.e. the exported javascript functions are found in generated bundle but not exported react component.


Update :

After adding output.library.name = "template-react-component-library" and output.library.type = "umd" in my webpack config , I was able to see the button tag (of exported component) in webpack bundle file but on importing the same button component from the template-react-component-library, I'm getting an empty object. Moreover, with exposing normal javascript functions from a library with same webpack configuration, I'm getting the javascript function and not empty object on importing it from library and that also without adding any output.library configs.

So now the question is how to solve the empty object on importing component from the library and why output.library behaves differently in exposing javascript functions and react components.


Repository link : https://github.com/dhiren-eng/template-react-component-library

Folder structure :

Folder structure

webpack.config.js :

const path = require("path")
module.exports = {
entry: "./src/index.ts",
output: {
    filename: "main.js",
    path: path.resolve(__dirname, 'build'),
    clean: true
},
module: {
    rules: [
        { test: /\.(jsx|js|tsx|ts)$/, exclude: /node_modules/, use: {loader: "babel-loader", options: {presets: ["@babel/preset-env","@babel/preset-react"]}} },
        { test: /\.(tsx|ts)$/, exclude: /node_modules/, use: ["ts-loader"] }
    ]
},
resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx']
}
}

tsconfig.json :

{
compilerOptions: {
"jsx": "react",
"module": "ESNext",
"declaration": true,
"declarationDir": "types",
"sourceMap": true,
"outDir": "build",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true
}}

Installed below packages. package.json :

{
  "name": "template-react-component-library",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "build": "webpack --config webpack.config.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
  "@babel/cli": "^7.21.5",
  "@babel/core": "^7.21.8",
  "@babel/preset-env": "^7.21.5",
  "@babel/preset-react": "^7.18.6",
  "@types/react": "^18.0.27",
  "babel-loader": "^9.1.2",
  "react": "^18.2.0",
  "ts-loader": "^9.4.2",
  "typescript": "^4.9.5",
  "webpack": "^5.76.2",
  "webpack-cli": "^5.0.1"
}

src/components/Button/Button.tsx :

import React from "react";

export interface ButtonProps { 
  label: string;
}

const Button = (props: ButtonProps) => {
  return <button>{props.label}</button>;
};

export default Button;

src/components/Button/index.ts :

export {default} from './Button'

src/components/index.ts :

export {default as Button} from './Button'

src/index.ts :

export {Button} from './components'

Build file generated, main.js :

    /*! For license information please see main.js.LICENSE.txt */
(()=>{"use strict";var t={408:(t,e)=>{Symbol.for("react.element"),Symbol.for("react.portal"),Symbol.for("react.fragment"),Symbol.for("react.strict_mode"),Symbol.for("react.profiler"),Symbol.for("react.provider"),Symbol.for("react.context"),Symbol.for("react.forward_ref"),Symbol.for("react.suspense"),Symbol.for("react.memo"),Symbol.for("react.lazy"),Symbol.iterator;var o={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},r=Object.assign,a={};function n(t,e,r){this.props=t,this.context=e,this.refs=a,this.updater=r||o}function c(){}function p(t,e,r){this.props=t,this.context=e,this.refs=a,this.updater=r||o}n.prototype.isReactComponent={},n.prototype.setState=function(t,e){if("object"!=typeof t&&"function"!=typeof t&&null!=t)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,t,e,"setState")},n.prototype.forceUpdate=function(t){this.updater.enqueueForceUpdate(this,t,"forceUpdate")},c.prototype=n.prototype;var s=p.prototype=new c;s.constructor=p,r(s,n.prototype),s.isPureReactComponent=!0;Array.isArray,Object.prototype.hasOwnProperty},294:(t,e,o)=>{o(408)}},e={};!function o(r){var a=e[r];if(void 0!==a)return a.exports;var n=e[r]={exports:{}};return t[r](n,n.exports,o),n.exports}(294)})();

After running the webpack build, 2 folders are generated :

  1. Types folder as mentioned in outDir property of tsconfig.json
  2. Build folder generated by webpack

enter image description here

1

There are 1 best solutions below

4
Dan Macak On

OK where should I start? First let me say that I am heavily in favour of distributing libraries in ESM (Ecma Script Module or simply module) format, since it's the most promising, modern module format out there.

That doesn't mean you can't distribute your lib in other formats (eg. UMD) as well, but I will focus on ESM in my answer because it's the preferred way to consume component libraries in React apps.

Configuring Webpack

Now that we know we want Webpack to produce ESM output, let me tell you Webpack's support for ESM is still experimental so there might be issues along the way.

Let's change webpack.config.js to produce ESM:

  output: {
    ...,
    library: {
      type: "module",
    },
  },
  externals: {
    react: "react",
  },
  experiments: {
    outputModule: true,
  },
  ...

With this config, the output will contain something like import * as React from 'react', which is what we want.

Now there is a problem, upon TS transpilation Webpack for some reason doesn't respect "allowSyntheticDefaultImports": true from TS config, which should make import React from 'react' work even though 'react' doesn't provide any default export. But for some reason, the output still tries to retrieve default from react, which is not present. This leads to issues

Cannot read properties of undefined (reading 'createElement')

which means react wasn't resolved correctly.

I didn't find any ideal solution here, but what worked for me is this:

  1. Add externalsType: "import", to webpack.config.js. This will import react dynamically, but is not desirable since it adds some 2kB of runtime to your output.
  2. Use import * as React from 'react' in your source files.

Both of those work and make it possible to use your component with import { Button } from 'your-lib';. But they both also kinda suck.

Alternative

Which begs the question, if there isn't more appropriate bundler for libraries. I'd suggest Rollup, either directly or through Vite. Unlike Webpack, it has excellent support for ESM format out of the box.

Add rollup.config.js

import typescript from "@rollup/plugin-typescript";
import babel from "@rollup/plugin-babel";
import external from "rollup-plugin-peer-deps-external";
import terser from "@rollup/plugin-terser";

export default {
  input: "src/Button.tsx",
  output: {
    file: "build/main.js",
    format: "es",
    sourcemap: true,
  },
  plugins: [
    typescript(),
    babel({
      presets: ["@babel/preset-react"],
    }),
    external(),
    terser(),
  ],
  external: ["react"],
};

This config is a precise mirror of your Webpack config, and is imo easier to configure and read. Of course you have to install the needed dependencies

yarn add -D rollup rollup-plugin-peer-deps-external @rollup/plugin-babel @rollup/plugin-terser @rollup/plugin-typescript tslib

Another side effect is that Rollup's output, at least in your case, is much smaller, and basically amounts to:

import t from "react";
const e = (e) => t.createElement("button", null, e.label);
export { e as Button };

whereas Webpack's is much bigger.

PS: If you encounter problems because the Rollup config uses import and export, just add "type": "module", to your package.json, which will force your project files to use ESM instead of CommonJS (and its module.exports and require()).