Piturzasty
Piturzasty

Reputation: 131

Webpack 5 module federation - hooks in remote module - not working

I'm trying to get a dynamic system in runtime with the help of Module Federation (webpack 5 feature). Everything works great, but when I add hooks to the 'producer' module (the module from which the host application dynamically imports the component) I get a mass of 'invalid rule of hooks' errors.

Warning: Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks. You can only call Hooks at the top level of your React function. For more information, see [LINK RULES OF HOOKS]
Warning: React has detected a change in the order of Hooks called by PluginHolder. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: [LINK RULES OF HOOKS]

I've already used externals field and added script tag in html files, I've used shared option with adding singleton field: true and specifying react and react-dom version Each time the console spits out a mass of errors

This is my method straight from the documentation to load the module

const loadComponent = (scope: string, module: string) => async (): Promise<any> => {
    // @ts-ignore
    await __webpack_init_sharing__('default');
    // @ts-ignore
    const container = window[scope];
    // @ts-ignore
    await container.init(__webpack_share_scopes__.default);
    // @ts-ignore
    const factory = await window[scope].get(module);
    return factory();
};

To load the remoteEntry.js file I use makeAsyncScriptLoader HOC with react-async-script like this:

const withScript = (name: string, url: string) => {
    const LoadingElement = () => {
        return <div>Loading...</div>;
    };

    return () => {
        const [scriptLoaded, setScriptLoaded] = useState<boolean>(false);

        const AsyncScriptLoader = makeAsyncScriptLoader(url, {
            globalName: name,
        })(LoadingElement);

        if (scriptLoaded) {
            return <PluginHolder name={name}/>;
        }

        return (
            <AsyncScriptLoader
                asyncScriptOnLoad={() => {
                    setScriptLoaded(true);
                }}
            />
        );
    };
};

PluginHolder is simple component which wraps loading module from loaded script (loading is done in effect)

 useEffect((): void => {
        (async () => {
            const c = await loadComponent(name, './Plugin')();
            setComponent(c.default);
        })();
    }, []);

 return cloneElement(component);

And on top of that is starter:

const [plugins, setPlugins] = useState<PluginFunc[]>([]);

    useEffect((): void => {
        pluginNames.forEach(desc => {
            const loaded = withScript(desc.name, desc.url);
            setPlugins([...plugins, loaded]);
        });
    }, []);

I do not use React.Lazy because I cannot use import(). What's more, in host application I set field eager: true in react and react-dom

My webpack.config.js (host) below:

require('tslib');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { DefinePlugin } = require('webpack');
const { ModuleFederationPlugin } = require('webpack').container;
// @ts-ignore
const AutomaticVendorFederation = require('@module-federation/automatic-vendor-federation');
const packageJson = require('./package.json');
const exclude = ['babel', 'plugin', 'preset', 'webpack', 'loader', 'serve'];
const ignoreVersion = ['react', 'react-dom'];

const automaticVendorFederation = AutomaticVendorFederation({
    exclude,
    ignoreVersion,
    packageJson,
    shareFrom: ['dependencies', 'peerDependencies'],
    ignorePatchVersion: false,
});

module.exports = {
    mode: 'none',
    entry: {
        app: path.join(__dirname, 'src', 'index.tsx'),
    },
    target: 'web',
    resolve: {
        extensions: ['.ts', '.tsx', '.js'],
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                exclude: '/node_modules/',
                use: 'ts-loader',
            },
            {
                test: /\.(s[ac]|c)ss$/i,
                exclude: '/node_modules/',
                use: [
                    'style-loader',
                    'css-loader',
                    'sass-loader',
                ],
            },
        ],
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: path.join(__dirname, 'public', 'index.html'),
            favicon: path.join(__dirname, 'public', 'favicon.ico'),
        }),
        new DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify('development'),
        }),
        new ModuleFederationPlugin({
            name: 'host',
            remotes: {},
            exposes: {},
            shared: {
                ...automaticVendorFederation,
                react: {
                    eager: true,
                    singleton: true,
                    requiredVersion: packageJson.dependencies.react,
                },
                'react-dom': {
                    eager: true,
                    singleton: true,
                    requiredVersion: packageJson.dependencies['react-dom'],
                },
            },
        }),
    ],
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'dist'),
        publicPath: 'http://localhost:3001/',
    },
    devServer: {
        contentBase: path.join(__dirname, 'dist'),
        port: 3001,
    },
};

And also my webpack.config.js from second module:

require('tslib');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { DefinePlugin } = require('webpack');
const { ModuleFederationPlugin } = require('webpack').container;
// @ts-ignore
const AutomaticVendorFederation = require('@module-federation/automatic-vendor-federation');
const packageJson = require('./package.json');
const exclude = ['babel', 'plugin', 'preset', 'webpack', 'loader', 'serve'];
const ignoreVersion = ['react', 'react-dom'];

const automaticVendorFederation = AutomaticVendorFederation({
    exclude,
    ignoreVersion,
    packageJson,
    shareFrom: ['dependencies', 'peerDependencies'],
    ignorePatchVersion: false,
});

module.exports = (env, argv) => {

    const { mode } = argv;
    const isDev = mode !== 'production';

    return {
        mode,
        entry: {
            plugin: path.join(__dirname, 'src', 'index.tsx'),
        },
        target: 'web',
        resolve: {
            extensions: ['.ts', '.tsx', '.js'],
        },
        module: {
            rules: [
                {
                    test: /\.tsx?$/,
                    exclude: '/node_modules/',
                    use: 'ts-loader',
                },
                {
                    test: /\.(s[ac]|c)ss$/i,
                    exclude: '/node_modules/',
                    use: [
                        'style-loader',
                        'css-loader',
                        'sass-loader',
                    ],
                },
            ],
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: path.join(__dirname, 'public', 'index.html'),
                favicon: path.join(__dirname, 'public', 'favicon.ico'),
            }),
            new DefinePlugin({
                'process.env.NODE_ENV': JSON.stringify('development'),
            }),
            new ModuleFederationPlugin({
                name: 'example',
                library: { type: 'var', name: 'example' },
                filename: 'remoteEntry.js',
                remotes: {},
                exposes: {
                    './Plugin': './src/Plugin',
                },
                shared: {
                    ...automaticVendorFederation,
                    react: {
                        eager: isDev,
                        singleton: true,
                        requiredVersion: packageJson.dependencies.react,
                    },
                    'react-dom': {
                        eager: isDev,
                        singleton: true,
                        requiredVersion: packageJson.dependencies['react-dom'],
                    },
                },
            }),
        ],
        output: {
            path: path.resolve(__dirname, 'dist'),
            publicPath: 'http://localhost:3002/',
        },
        devServer: {
            contentBase: path.join(__dirname, 'dist'),
            port: 3002,
        },
    };
};

Do you have any experience with it or any clues - I think the point is that 2 applications use 2 instances of the react but these are my guess. Is there something wrong with my configuration?

Upvotes: 13

Views: 17196

Answers (1)

anonymous
anonymous

Reputation: 186

Make sure you are adding in shared dependencies to your webpack.config file.

See below for example:

  plugins: [
    new ModuleFederationPlugin(
      {
        name: 'MFE1',
        filename:
          'remoteEntry.js',

        exposes: {
          './Button':'./src/Button',
        },
        shared: { react: { singleton: true }, "react-dom": { singleton: true } },
      }
    ),
    new HtmlWebpackPlugin({
      template:
        './public/index.html',
    }),
  ],
};

I have both the host and remote project setup with this shared property. Fixed it for me when hooks broke my host app. It's because there are duplicate react dependencies, regardless if the version are the same you will get this error.

Upvotes: 3

Related Questions