Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using one webpack project inside another with react hooks causes an error

There are three create-react-apps customised using react-app-rewired, both are using the same version of the following packages:

"react": "^16.12.0",
"react-app-rewired": "^2.1.5",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2",
"react-scripts": "3.3.0",

App 1, the "Main" application is a very simple shell for the other two apps, public/index.html kind of looks like so:

<body>
    <div class="main-app"></div>
    <div class="sub-app-1"></div>
    <div class="sub-app-2"></div>
    <script src="sub-app-1/static/js/bundle.js" async></script>
    <script src="sub-app-1/static/js/0.chunk.js" async></script>
    <script src="sub-app-1/static/js/main.chunk.js" async></script>
    <script src="sub-app-2/static/js/bundle.js" async></script>
    <script src="sub-app-2/static/js/0.chunk.js" async></script>
    <script src="sub-app-2/static/js/main.chunk.js" async></script>
</body>

This works well enough, all three apps are rendered correctly. Now the requirements have changed slightly where the one and only component from sub-app-2 for example <PictureFrame /> needs to be included in sub-app-1, I have created a stub component in the main project, like so:

const NullPictureFrame = () => {
    return <div></div>;
}

export const PictureFrame = (props: Props) => {
    const forceUpdate = useForceUpdate();
    useEventListener(window, "PictureFrameComponent.Initialized" as string, () => forceUpdate());

    const PictureFrame = window.PictureFrameComponent || NullPictureFrame;
    return <PictureFrame />
}

I don't think the details of the hooks matter, but they do work when run in a stand alone fashion. The sub-app-2 does something similar to this

window.PictureFrameComponent = () => <PictureFrame />

which seems to work in theory, but in practice I end up with the following error

The above error occurred in the <PictureFrame> component:
    in PictureFrame (at src/index.tsx:17)
    in Router (at src/index.tsx:16)
    in Unknown (at PictureFrames/index.tsx:21)
    in PictureFrame (at src/index.tsx:32) <--- in `main-app`

Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://reactjs.org/docs/error-boundaries.html to learn more about error boundaries. index.js:1
Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/warnings/invalid-hook-call-warning.html for tips about how to debug and fix this problem.

main-app and sub-app-2 are loading two different versions of react and this is causing an issue when using hooks.

I have tried to update my webpack config based on the advice in this github thread but it appears that sub-app-2 cannot find the reference to react using this approach. My main-app/config-overrides.js looks like so:

config.resolve.alias["react"] = path.resolve(__dirname, "./node_modules/react");
config.resolve.alias["react-dom"] = path.resolve(__dirname, "./node_modules/react-dom");

and my sub-app-1/config-overrides.js looks like so:

config.externals = config.externals || {};
config.externals.react = {
    root: "React",
    commonjs2: "react",
    commonjs: "react",
    amd: "react"
};
config.externals["react-dom"] = {
    root: "ReactDOM",
    commonjs2: "react-dom",
    commonjs: "react-dom",
    amd: "react-dom"
};

Which results in no errors, but also the code from sub-app-2 not being initialised by webpack as react/react-dom cannot be found


Edit 1: Clarification

  • the 3 apps in question are 3 completley seperate (git) projects, just all using the same dependencies.
  • The only "dependency" permitted between the projects is this very loose coupling by using globals, Events, Window.postMessage or similar.
  • My initial approach of const PictureFrame = window.PictureFrameComponent || NullPictureFrame; works fine without using hooks, which the teams have been using until now, which has reinforced the idea of very lose dependencies detailed above
  • There is the "obvious" way of making "react", "react-dom" and anything that dependends on them part of the externals in the main-app and loading them in that way
like image 667
Meberem Avatar asked Jan 17 '20 10:01

Meberem


4 Answers

I see you have taken an approach to have multiple projects under one directory/repository/codebase, which I think it is not the best approach. You should split and have all your projects independently, a library sub-project where you have all the shared code/components and then make each project to have access to what it needs.

You can read an article I have wrote about Share React Native components to multiple projects, which I suggest 3 ways for sharing code between projects. I'll emphasize to the Webpack/Babel way, which you can utilize module-resolver package to configure directory aliases. You can use .babelrc and module-resolver to configure your outer dependencies like this:

{
  "presets": ["@babel/preset-react"]
  "plugins": [
    "@babel/plugin-transform-runtime",
    [
      "module-resolver",
      {
        "root": ["./"],
        "extensions": [".js", ".jsx", ".ts", ".tsx"],
        "stripExtensions": [".js", ".jsx", ".ts", ".tsx"],
        "alias": {
          "@components": "./components",
          "@screens": "./screens",
          "@utils": "./utils",
          "@data": "./data",
          "@assets": "./assets",
          "@app": "./",
          "@myprojectlib": "../MyProject-Library",
          "@myprojecti18n": "../MyProject-Library/i18n/rn",
          "@myprojectbackend": "../MyProject-Backend/firebase/functions",
        }
      }
    ]
  ]
}

After that, you can use something like:

import { PictureFrame } from '@myprojectlib/components/PictureFrame';

and you'll have it available on each project you have configured to have access to "@myprojectlib": "../MyProject-Library".

With this way, you can have multiple versions of React and any package inside your project because each project will be a standalone codebase with it's own dependencies but with the same shared components/functions.

like image 53
Christos Lytras Avatar answered Oct 11 '22 02:10

Christos Lytras


As quoted in React.js documentation,


React Hooks:

Hooks are essentially built to access state and other react features in a classless component.

Rules to keep in mind while working with hooks

  1. Only Call Hooks at the Top Level

  2. Only Call Hooks from React Functions

From the details that you've posted, I believe you've already taken into consideration the above points.

For root cause analysis you probably need to set some debugging statements in your code or log messages on the console to investigate the component lifecycle.


Also, it'll be a lot better if you could reconstruct/rearrange the application as follows:

public/index.html

<body>
<div id="root" class="main-app"></div>
<script>
<!-- scrpits can be kept here -->
</script>
</body>

Create another component Main App and include sub-app-1 and sub-app-2 there.

and since you're using "react-dom": "^16.12.0" and "react-router-dom": "^5.1.2", it'll be easier for your set the routes.


Can't really say this will solve your case, but it's a good practice to include.

All the best.

like image 26
Ronnie Avatar answered Oct 11 '22 03:10

Ronnie


I have been faced the same issue in my project, the solution is simple.

you can go to all your subproject(s) and run bellow command.

npm link ../main_app/node_modules/react

Referece: https://reactjs.org/warnings/invalid-hook-call-warning.html#duplicate-react

like image 2
patelmayankce Avatar answered Oct 11 '22 01:10

patelmayankce


Partial Solution: Use the expose-loader webpack plugin

Using the expose-loader in the main-app allows both sub-app-1 and sub-app-2 to use the libraries from main-app

sub-app-1 and sub-app-2 have a config-overrides.js to indicate they rely on external libraries

module.exports = function override(config, env) {
  config.externals = config.externals || {};
  config.externals.axios = "axios";
  config.externals.react = "React";
  config.externals["react-dom"] = "ReactDOM";
  config.externals["react-router"] = "ReactRouterDOM";

  return config;
}

main-app is responsible for providing those libraries. This can be achieved either by embedding the libraries using <script /> tags or by using the expose-loader, the config-overrides.js effectively looks like so:

module.exports = function override(config, env) {
  config.module.rules.unshift({
    test: require.resolve("react"),
    use: [
      {
        loader: "expose-loader",
        options: "React"
      }
    ]
  });
  config.module.rules.unshift({
    test: require.resolve("react-dom"),
    use: [
      {
        loader: "expose-loader",
        options: "ReactDOM"
      }
    ]
  });
  config.module.rules.unshift({
    test: /\/react-router-dom\//, // require.resolve("react-router-dom"), did not work
    use: [
      {
        loader: "expose-loader",
        options: "ReactRouterDOM"
      }
    ]
  });

  return config;
};

Now when I visit my main-app page, I can see React, ReactDOM and ReactRouterDOM on the window object. Now it is possible for my apps to load out of order, but now we need to ensure that the main-app is loaded first, fortunately there is a somewhat straightfoward way. In public/index.html I remove my referenced sub-apps

<body>
    <div class="main-app"></div>
    <div class="sub-app-1"></div>
    <div class="sub-app-2"></div>
-    <script src="sub-app-1/static/js/bundle.js" async></script>
-    <script src="sub-app-1/static/js/0.chunk.js" async></script>
-    <script src="sub-app-1/static/js/main.chunk.js" async></script>
-    <script src="sub-app-2/static/js/bundle.js" async></script>
-    <script src="sub-app-2/static/js/0.chunk.js" async></script>
-    <script src="sub-app-2/static/js/main.chunk.js" async></script>
</body>

and in my index.tsx I can dynamically load the scripts in question and wait for them

// Will dynamically add the requested scripts to the page
function load(url: string) {
  return new Promise(function(resolve, reject) {
    var script = document.createElement("script");
    script.type = "text/javascript";
    script.async = true;
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// Load Sub App 1
Promise.all([
    load(`${process.env.SUB_APP_1_URL}/static/js/bundle.js`),
    load(`${process.env.SUB_APP_1_URL}/static/js/0.chunk.js`),
    load(`${process.env.SUB_APP_1_URL}/static/js/main.chunk.js`)
]).then(() => {
    console.log("Sub App 1 has loaded");
    window.dispatchEvent(new Event("SubAppOne.Initialized"));
    const SubAppOne = window.SubAppOne;
    ReactDOM.render(<SubAppOne />, document.querySelector(".sub-app-1"))
});

// Load Sub App 2
Promise.all([
    load(`${process.env.SUB_APP_2_URL}/static/js/bundle.js`),
    load(`${process.env.SUB_APP_2_URL}/static/js/0.chunk.js`),
    load(`${process.env.SUB_APP_2_URL}/static/js/main.chunk.js`)
]).then(() => {
    console.log("Sub App 2 has loaded");
    window.dispatchEvent(new Event("PictureFrameComponent.Initialized")); // Kicks off the rerender of the Stub Component
    window.dispatchEvent(new Event("SubAppTwo.Initialized"));
    const SubAppTwo = window.SubAppTwo;
    ReactDOM.render(<SubAppTwo />, document.querySelector(".sub-app-2"))
});

Now main-app has all the shared dependencies like React, and will load the two sub-apps when the dependencies are ready. The PictureFrameComponent now works as expected with hooks! It would be preferrable if the loading could happen in any order, i.e. sub-app-1 could be loaded before main-app, however given the importance of the main-app and the minor addition in functionality provided by the PictureFrameComponent this could be a passable solution for some projects.

like image 1
Meberem Avatar answered Oct 11 '22 03:10

Meberem