fkhadra / react-toastify

React notification made easy 🚀 !
https://fkhadra.github.io/react-toastify/introduction
MIT License
12.33k stars 676 forks source link

Mixing cjs and esm imports results in duplicated version of the lib because of "exports" setup in "package.json" #1061

Open mishani0x0ef opened 4 months ago

mishani0x0ef commented 4 months ago

Do you want to request a feature or report a bug?

bug

What is the current behavior?

Currently, for apps that mix cjs and esm imports, bundling of react-toastify may result in including both variants of the lib into the final bundle.

image

Assuming we have: (full reproducible example can be found here - https://github.com/mishani0x0ef/react-toastify-esm-cjs-issue)

import React from "react";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { Cjs } from "./Cjs";
import { Esm } from "./Esm";

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <ToastContainer />
        <Esm />
        <Cjs />
      </header>
    </div>
  );
}

export default App;
// esm import
import { toast } from "react-toastify";

export function Esm() {
  const notify = () => toast("esm!");

  return <button onClick={notify}>Notify esm!</button>;
}
// cjs import
const { toast } = require("react-toastify");

export function Cjs() {
  const notify = () => toast("cjs!");

  return <button onClick={notify}>Notify cjs!</button>;
}

In the example, ToastContainer initialized in esm file, while also used in cjs file and other esm file.

However, toast will only be shown for esm version because the lib itself is duplicated in the final bundle:

https://github.com/fkhadra/react-toastify/assets/12882856/b98b6fd0-d155-47c3-92ad-3437df333bad

Why it may be an issue?

Even though mixing cjs and esm in a single app isn't common - cjs version may be used by downstream dependency.

In my case, it's used as a peer dependency of a component library that provides only the cjs version.

The actual behavior may be different in different bundling tools. I can definitely say that it's reproducible in webpack with esbuild loader; in esbuild itself; and in whatever create-react-app has for the setup of webpack.

What is the expected behavior?

As per this comment from Evan Wallace the issue is caused by using exports along with main/module. So, removing exports from package.json will fix the issue (I have tried it locally and it really works)

I totally understand this is a breaking change, and similar decisions should be made very carefully. Also, exports may be useful in some other scenarios.

So, I respect your time and don't expect a quick resolution.

It just would be nice to hear your thoughts.

mishani0x0ef commented 4 months ago

Just to add, for those who faced the same issue and are looking for a solution.

If you can use esm instead of cjs - just use it. It's better, believe me 😄

If you cannot avoid cjs - as a workaround for this issue use cjs everywhere (assuming you cannot tune your bundler to workaround it in other way).

I know it's painful, but this is what currently available.

const { toast } = require('react-toastify');

Depending on your project configuration, you may also need to:

mishani0x0ef commented 4 months ago

Also, there is tricky workaround for webpack.

The idea is to remove exports section before compilation using a custom plugin:

const { validate } = require('schema-utils');
const path = require('path');
const fs = require('fs');

const schema = {
  type: 'object',
  properties: {
    path: {
      type: 'string',
    },
  },
};

class DualCjsEsmWorkaroundPlugin {
  static name = 'DualCjsEsmWorkaroundPlugin';

  constructor(options) {
    validate(schema, options);

    const defaultOptions = {
      path: null,
    };

    this.options = { ...defaultOptions, ...options };
  }

  apply(compiler) {
    if (!this.options.path) {
      return;
    }

    compiler.hooks.initialize.tap(DualCjsEsmWorkaroundPlugin.name, () =>
      this._removeExportsWhenPossible(),
    );
  }

  _removeExportsWhenPossible() {
    try {
      const packageJsonPath = path.join(this.options.path, 'package.json');
      const packageJson = require(packageJsonPath);
      const canRemoveExports = packageJson.module && packageJson.main && packageJson.exports;

      if (!canRemoveExports) {
        return;
      }

      const packageJsonWithoutExports = JSON.stringify(
        { ...packageJson, exports: undefined },
        null,
        2,
      );

      fs.writeFileSync(packageJsonPath, packageJsonWithoutExports);
    } catch (error) {
      console.error(
        `[${DualCjsEsmWorkaroundPlugin.name}]: error while trying to remove externals: ${error}`,
      );
    }
  }
}

// webpack.config.js

module.exports = {
  // other configs

  plugins: [
    new DualCjsEsmWorkaroundPlugin({ path: path.join(__dirname, 'node_modules/react-toastify') }),

    // other plugins
  ],
};