Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shared react #1165

Closed
ValeraKorovelkov opened this issue Nov 16, 2023 · 13 comments
Closed

Shared react #1165

ValeraKorovelkov opened this issue Nov 16, 2023 · 13 comments

Comments

@ValeraKorovelkov
Copy link

Describe the bug or question
I have two issues with sharing react, they might be related, not sure.

  1. Here is my import-map
<script type="systemjs-importmap">
  {
      "imports": {
          "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.3/lib/system/single-spa.min.js",
          "react": "https://cdn.jsdelivr.net/npm/@esm-bundle/react@17.0.1/system/react.production.min.js",
          "react-dom": "https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.1/system/react-dom.production.min.js",
          "@root-config": "{{MFE_JS_URL}}"
      },
      "scopes": {
          "https://localhost:3000/": {
              "react": "https://cdn.jsdelivr.net/npm/@esm-bundle/react@17.0.2/system/react.production.min.js",
              "react-dom": "https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.2/system/react-dom.production.min.js"
          }
      }
  }
</script>

After I open the page with single-spa-react app it loads react twice
image

  1. If you remove the react and react-dom from imports, I'm getting an error
    Unable to resolve bare specifier 'react' from https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.2/system/react-dom.production.min.js

With the import-map without scopes all works perfectly

 "imports": {
          "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.3/lib/system/single-spa.min.js",
          "react": "https://cdn.jsdelivr.net/npm/@esm-bundle/react@17.0.1/system/react.production.min.js",
          "react-dom": "https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.1/system/react-dom.production.min.js",
          "@root-config": "{{MFE_JS_URL}}"
      }

What can be the problem?

@MilanKovacic
Copy link
Collaborator

MilanKovacic commented Nov 16, 2023

React is being loaded twice as one of the consumers matches the specific scope while the other one falls back to the global scope. Essentially, your import map is configured as: if request for these modules comes from localhost:3000, use this; otherwise, fall back to global scope.
This can create issues as react does not work correctly when there are multiple versions present on the same page. It also does not work correctly when the same version of react is loaded (consumed) multiple times.

If you remove the react and react-dom from imports, I'm getting an error
Unable to resolve bare specifier 'react' from https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.2/system/react-dom.production.min.js
With the import-map without scopes all works perfec

By default react, and react-dom are externalized in webpack configurations (if created with create-single-spa). They will not be bundled and imports will be left as-is. When you remove them from import maps, browser encounters a line in the code such as "import ... from 'react'", and you get the error as it can not locate "react".

@ValeraKorovelkov
Copy link
Author

@MilanKovacic, thank you for your responses! Could you please provide further clarification?
I currently have a root app using React 16, which I intend to load from node_modules. Additionally, there are several single-spa apps using React 17, and I want them to load React from the import map.
image
If I understand correctly, to ensure proper functionality, both the root app and the single-spa apps should have the same version of React?

I've used a create-react-app and craco(to update webpack) for my single-spa apps.

@MilanKovacic
Copy link
Collaborator

MilanKovacic commented Nov 16, 2023

This setup could work correctly. In root-config, exclude react, and react-dom from externals so that they are bundled. In microfrontends, set react, and react-dom as externals, and set them in import maps (you do not need to scope them). Best practice is for root-config not to use any framework, and to share dependencies like react accross microfrontends.

@ValeraKorovelkov
Copy link
Author

ValeraKorovelkov commented Nov 16, 2023

The root app doesn't have any externals, all issues that I described were without externals in the root ap.

you do not need to scope them

We have multiple single-spa apps owned by different teams, and I aimed to use scopes as a means for each team to update their dependencies without affecting other teams' work.

Best practice is for root-config not to use any framework, and to share dependencies like react accross microfrontends.

This is our goal. Root app is a monolith legacy, we're gradually working on separating it step by step. However, this process will take some time.

@MilanKovacic
Copy link
Collaborator

MilanKovacic commented Nov 16, 2023

The root app doesn't have any externals, all issues that I described were without externals in the root.
After I open the page with single-spa-react app it loads react twice

I am not sure what the issue is. This is an expected behavior as you have one react in global scope of the import one, and one scoped react. All requests originating from https://localhost:3000/ will utilize the scoped one, while all other consumers will utilize the global one, which could lead to multiple versions/instances being loaded.

If you remove the react and react-dom from imports, I'm getting an error

This is happening because you have react/react-dom externalized in one of the applications.

We have multiple single-spa apps owned by different teams, and I aimed to use scopes as a means for each team to update their dependencies without affecting other teams' work.

You can use scopes for this. This is an organizational decision — be careful of microfrontend anarchy.

@ValeraKorovelkov
Copy link
Author

I am not sure what the issue is. This is an expected behavior as you have one react in global scope of the import one, and one scoped react. All requests originating from https://localhost:3000/ will utilize the scoped one, while all other consumers will utilize the global one, which could lead to multiple versions/instances being loaded.

But I have only one consumer. In my example, there is one single-spa app. The root app uses react from the node_modules, so it should not consume a react from import-map. So with one single-spa react, that has external react it loads scoped and global react.

This is happening because you have react/react-dom externalized in one of the applications.

But I have scoped react.

{
      "imports": {
          "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.3/lib/system/single-spa.min.js",
          "@root-config": "{{MFE_JS_URL}}"
      },
      "scopes": {
          "https://localhost:3000/": {
              "react": "https://cdn.jsdelivr.net/npm/@esm-bundle/react@17.0.2/system/react.production.min.js",
              "react-dom": "https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.2/system/react-dom.production.min.js"
          }
      }
  }

With this import-map I'm getting the error
Unable to resolve bare specifier 'react' from https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.2/system/react-dom.production.min.js
Same conditions, one consumer and root that uses node_modules react.

@MilanKovacic
Copy link
Collaborator

Please post full root-config index file, and webpack/craco configurations.

@ValeraKorovelkov
Copy link
Author

ValeraKorovelkov commented Nov 16, 2023

Webpack config for single-spa app(i've removed jest configuration)

const singleSpaApplicationPlugin = require('craco-plugin-single-spa-application');
const orgName = 'org';
const projectName = 'singleSpa';

module.exports = {
    webpack: {
        plugins: {
            remove: ['ManifestPlugin'],
        },
        configure: {
            externals: ['react', 'react-dom']
        }
    },
    plugins: [
        {
            plugin: {
                ...singleSpaApplicationPlugin,
                overrideCracoConfig: ({ cracoConfig, pluginOptions }) => {
                    const cracoConfigOverride =
                        singleSpaApplicationPlugin.overrideCracoConfig({
                            cracoConfig,
                            pluginOptions,
                        });

                    // Don't remove HtmlWebpackPlugin for ephemerals to work
                    cracoConfigOverride.webpack.plugins.remove =
                        cracoConfigOverride.webpack.plugins.remove.filter(
                            (el) => el !== 'HtmlWebpackPlugin',
                        );

                    return cracoConfigOverride;
                },
            },
            options: {
                orgName,
                projectName,
                entry: `src/${orgName}-${projectName}`,
                orgPackagesAsExternal: false, 
                reactPackagesAsExternal: false,
                minimize: process.env.NODE_ENV === 'production', 
                rootDirectoryLevel: 1,
            },
        },
    ],
};

Root config file. We use it as a separate entry in the webpack

entry: {
    reactapp: [path.join(__dirname, 'index.jsx')],
    mfe: [path.join(__dirname, 'root-config.js')],
},

There is moreregisterApplication, but I'm testing this one so I believe others are irrelevant.

import { registerApplication, start, addErrorHandler } from 'single-spa';

window.addEventListener('single-spa:app-change', event => {
    const { appsByNewStatus } = event.detail;

    // As core styles were overwriting our styles, this is the solution we came up with:
    // Looking for core styles index
    const coreStylesIndex = [...document.styleSheets].findIndex(
        styleSheet =>
            styleSheet.href && styleSheet.href.includes('/css/app.css'),
    );

    // If it is micro FE that does not need core styles, then set it to true
    if (coreStylesIndex >= 0) {
        const isDisabled = appsByNewStatus.MOUNTED.some(mountedApp =>
            DISABLED_CORE_STYLES_APPS.includes(mountedApp),
        );

        document.styleSheets[coreStylesIndex].disabled = isDisabled;
    }
});

window.addEventListener('single-spa:app-change', event => {
    const { newAppStatuses } = event.detail;
    console.log('Some applications were mounted/unmounted: ');
    console.log(newAppStatuses);
});

window.addEventListener('single-spa:routing-event', event => {
    const { newAppStatuses } = event.detail;

    console.log(newAppStatuses); // { app1: MOUNTED, app2: NOT_MOUNTED }
});

registerApplication({
    name: WEB_APP_NAME,
    app: async () => {
        if (process.env.NODE_ENV === 'production') {
            await System.import(`${WEB_APP_NAME}/config`);
        }
        return System.import(WEB_APP_NAME);
    },
    activeWhen: location => {
        const { pathname } = location;

        return pathname.startsWith(`${prefix}/singleSpa`);
    },
    customProps: () => {
        const context = document.getElementById(
            `single-spa-application:${WEB_APP_NAME}`,
        )?.dataset;

        return {
            isEphemeralEnv: formatBool(context?.isEphemeralEnv),
            allFeatureFlags: { ...allFeatureFlags },
        };
    },
});

localStorage.setItem('devtools', 'false');

addErrorHandler(err => {
    System.delete(System.resolve(err.appOrParcelName));
});

start();

@MilanKovacic
Copy link
Collaborator

Please add the index.html file.

@ValeraKorovelkov
Copy link
Author

I've removed some sensitive data

{% load i18n %}
<!doctype html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
        <script src="//ajax.googleapis.com/ajax/libs/webfont/1.5.6/webfont.js"></script>
        <script src={{JARIS_CDN_URL}}></script>
        <script>
            WebFont.load({
                google: {
                    families: ['Montserrat']
                 },
                 typekit: {
                    id: 'pmn7pbb'
                 }
            });
        </script>
        {% block css %}
        <link rel="preconnect" href="https://fonts.gstatic.com">
        <link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
        {% endblock %}
        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
        <meta name="application-name" content="HotSpot" />
        <meta name="apple-mobile-web-app-capable" content="yes" />
        <meta name="apple-mobile-web-app-status-bar-style" content="black" />
        <meta name="format-detection" content="telephone=no" />
        <meta name="format-detection" content="email=no" />
        <meta name="msapplication-config" content="images/favicons/browserconfig.xml">
        <meta name="theme-color" content="#f9f9f9">
        <meta id="show-tour-intercom" data-value="{{ SHOW_TOUR }}">
        <meta id="base-url-intercom" data-value="{{ BASE_URL}}">
        <meta id="intercom-tour-id" data-value="{{ INTERCOM_TOUR_ID }}">

        {% comment %}
        ***************************************************

        Setup our MicroFE's!!!

        ***************************************************
        {% endcomment %}

        <script src="https://cdn.jsdelivr.net/npm/regenerator-runtime@0.13.7/runtime.min.js"></script>

        <meta name="importmap-type" content="systemjs-importmap" />
        <script type="systemjs-importmap">
            {
                "imports": {
                    "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.3/lib/system/single-spa.min.js",
                    "react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.2.0/umd/react-router-dom.min.js",
                    "@root-config": "{{MFE_JS_URL}}"
                },
                "scopes": {
                    "https://localhost:3000/": {
                        "react": "https://cdn.jsdelivr.net/npm/@esm-bundle/react@17.0.2/system/react.production.min.js",
                        "react-dom": "https://cdn.jsdelivr.net/npm/@esm-bundle/react-dom@17.0.2/system/react-dom.production.min.js"
                    }
                }
            }
        </script>
        <script type="systemjs-importmap" src="{{IMPORT_MAP_URL}}"></script>

        <link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@5.9.3/lib/system/single-spa.min.js" as="script" crossOrigin="anonymous">

        <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script>

        <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script>

        <script type="application/javascript">
            window.apiUrl = '{{ API_URL }}';
        </script>

        {% if should_mfe_wait_for_dom %}
        <script>
            window.addEventListener('DOMContentLoaded', function(){
                System.import('@root-config');
            });
        </script>
        {% else %}
        <script>
            System.import('@root-config');
        </script>
        {% endif %}
    </head>
    <body>
        <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
        <script async src="//www.google-analytics.com/analytics.js"></script>
        <script src="{{ APP_JS_URL }}"></script>
        <script src="{{ REACTAPP_JS_URL }}" data-name="react-app" type="application/javascript"></script>
    </body>
</html>

@ValeraKorovelkov
Copy link
Author

@MilanKovacic Hi! I managed to reproduce it with the default create-single-spa projects.
Here is the repository
https://github.com/ValeraKorovelkov/single-spa-shared-react-bug

@MilanKovacic
Copy link
Collaborator

Hi, great. I was busy, but now it should be easier to locate the issue.

@MilanKovacic
Copy link
Collaborator

Hi, did you manage to solve the issue?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants