module-federation / module-federation-examples

Implementation examples of module federation , by the creators of module federation
https://module-federation.io/
MIT License
5.4k stars 1.7k forks source link

Material-ui/lab and material-ui/pickers overriding styles when being imported from remote federated modules #1071

Closed jmelendez-cbs closed 2 years ago

jmelendez-cbs commented 2 years ago

We are using webpack 5 and a design system that hosts federated modules from material ui. As of now we have the following setup for the ModuleFederationPlugin.

    new ModuleFederationPlugin({
      name: 'rosters-web-app',
      // TODO: Make optional depending if the DS is running locally
      remotes: {
        'dataeng-ds': federationUrl
      },
      shared: {
        ...pkgJson.dependencies,
        "@material-ui/core": {
          singleton: true,
        },
        react: { 
          singleton: true, 
          requiredVersion: pkgJson.dependencies.react 
        },
        "react-dom": { 
          singleton: true, 
          requiredVersion: pkgJson.dependencies["react-dom"] 
        }
      },
    })

This is how a page on the app displays:

Screen Shot 2021-08-05 at 11 51 56 AM

However, when we use a component from material-ui/lab or material-ui/pickers it seems the styles get messed up.

Notice the buttons are now unstyled/overwritten

Screen Shot 2021-08-05 at 11 53 44 AM

Does anyone have an idea on why this is happening?

Note: I have also tried adding

      "@material-ui/lab": {
          singleton: true,
        },

to the shared list and it still does not work.

Screen Shot 2021-08-05 at 12 04 59 PM

JohnDaly commented 2 years ago

It could be because you're not specifying a version requirement for the @material-ui/core dependency, and you're getting a different copy which introduces the styling discrepancies.

When Webpack tries to determine which copy of that library to use, it will find the highest version available (unless told otherwise). If @material-ui/lab or @material-ui/pickers have the @material-ui/core package listed as a peerDependency, then you could be getting a version of the library that causes the problem you're seeing.

yescine commented 2 years ago

@jmelendez-cbs I am having the same issue, even when I specify the shared dependencies with their version!

yescine commented 2 years ago

@jmelendez-cbs I am having the same issue, even when I specify the shared dependencies with their version!

As mentioned in #548 sharing "@material-ui/" as singleton solve the issue, but how could we know which library has to be singleton !?

jmelendez-cbs commented 2 years ago

It could be because you're not specifying a version requirement for the @material-ui/core dependency, and you're getting a different copy which introduces the styling discrepancies.

When Webpack tries to determine which copy of that library to use, it will find the highest version available (unless told otherwise). If @material-ui/lab or @material-ui/pickers have the @material-ui/core package listed as a peerDependency, then you could be getting a version of the library that causes the problem you're seeing.

I went through and made sure the peerDependencies were shared that material/lab required but still no luck.

jmelendez-cbs commented 2 years ago

@jmelendez-cbs I am having the same issue, even when I specify the shared dependencies with their version!

As mentioned in #548 sharing "@material-ui/" as singleton solve the issue, but how could we know which library has to be singleton !?

Yeah, from what I understand, the libraries that use a context should be shared as singletons. I think the issue here could be the way that material-ui imports and exports some components for the lab. In particular the Button component. I traced my issue back to the Button.

An extra styled sheet for ButtonBase was being added to the of my index.html doc which was over-writing the styles.

I have not solved this issue yet.

yescine commented 2 years ago

@jmelendez-cbs have same issue with Floating Action Button from Mui-v4.10.0 that use the same ButtonBase component, more deep dive on how Module Federation deal with large Css Framework like Mui is needed !( it's beyond my scope :( )

ScriptedAlchemy commented 2 years ago

How to know if a lib needs to be a singleton. Mainly you have to see what happens. In general anything that uses react context needs to be a singleton. Things like redux

slavab89 commented 2 years ago

As more and more libraries rely on react context, it will cause the list of singletons to be quite long. Is there some kind of plan to deal with it more elegantly?

ScriptedAlchemy commented 2 years ago

Not the concern of Webpack. We compile code that's it.

Ways to work around the problem tho, ship library as remote themselves. So it's centrally owned.

I could write a plugin that finds any use of react context in its parser and set those as singleton. However that's beyond the scope of my time and capacity. But it's possible to traverse the module graph automatically

slavab89 commented 1 year ago

Although this has been marked as closed, i would like to continue and try to understand the issue. Is this material-ui specific thing?? We don't notice this with other libraries that we're using.

The effects of this issue are understood. It seems to download and load the same component twice (For example, Button or Typography) and if this happens at various times of the page, it will cause the component to create styles once more and add them to . The big question is why it does that.

To my understanding, the solution of using material-ui/core/ (With a Trailing Slash) will make everything under this as a shared component individually, which in turn creates a whole bunch of files (As they are all shared individually). Although this should not make an issue since the browser can cache them, and initially if it uses http2 it will download a bunch of them together, it still creates a bunch more files. BUT, this does solve the issue since webpack notices the file paths are exactly the same..?

Can it be that, due to different tree-share combinations of those components, it bundles Button in different files between the remotes and therefore forces the loading of that component once more?

ScriptedAlchemy commented 1 year ago

Although this has been marked as closed, i would like to continue and try to understand the issue.

Is this material-ui specific thing?? We don't notice this with other libraries that we're using.

The effects of this issue are understood. It seems to download and load the same component twice (For example, Button or Typography) and if this happens at various times of the page, it will cause the component to create styles once more and add them to .

The big question is why it does that.

To my understanding, the solution of using material-ui/core/ (With a Trailing Slash) will make everything under this as a shared component individually, which in turn creates a whole bunch of files (As they are all shared individually).

Although this should not make an issue since the browser can cache them, and initially if it uses http2 it will download a bunch of them together, it still creates a bunch more files.

BUT, this does solve the issue since webpack notices the file paths are exactly the same..?

Can it be that, due to different tree-share combinations of those components, it bundles Button in different files between the remotes and therefore forces the loading of that component once more?

Happy to continue this. From a comprehensive standpoint.

Here's how I understand the issue. That package is like a highly interconnected monorepo of other packages. So /lab and /core most likely import something like /context-bus or /themes as well.

And it's those that need to be singletons - it's not a normal package where everything is just in one package. You're installing many material sub dependencies when using one of the higher level ones.

So the problem ends up as, there's still something both those packages rely on, another node module from materials repository organization.

While you're federating the high level, one, there are likely internals that also need to be shared, but are not necessarily immediately obvious because they're mostly used internally. By higher level packages you import from. Hence why my usual suggestion is if you don't know what all is actually shared. Or used. Sharing "@mui/" is going to share any requires that starts with mui, regardless of where or how deep the import is.

Then you could console log webpack share scopes. And see all the packages actually being used by the apps - manually add them one by one till you find which one actually needs to be shared as a singleton. But doing the trailing slash is a great way to flush out all the things actually imported. And can choose.

Like sentry, similar deal. I can "@sentry/" and see that share scope contains /hub /tracing /browser /client /core.

This is a similar issue some big npm packages have. So In a really tough case I can just use the trailing slash and that'll probably work but can make builds a little larger. So using it just to flush out what are all the actual dependencies that are used and console log, then process of elimination

I know it's not great, and I will try to implement a "hinting" capability into Medusa so it can show you this stuff in like a GUI app. So I know it's not super straightforward to just find something - but you can get the info. I'm hoping to improve the tools to help give you insights about it

ScriptedAlchemy commented 1 year ago

const federationConfig = {
  name: "stores_mfe",
  remotes: {
    coco: 'coco'
  },
  shared: {
    "@mui/": {}
  }
};

// in app code
console.log(__webpack_share_scopes__.default)
slavab89 commented 1 year ago

Thanks for the detailed explanation, it totally makes sense.

Do i understand correctly, that the singleton indication should be the same for all remotes? Meaning that its not possible for one remote to use @mui/core and the other @mui/. Since it would mean that one remote has the "root" and the other has all the "sub packages", and it would create a "conflict". __webpack_share_scopes__.default also shows both of versions.

In any case, i've tried adding all dependencies from the material ui package.json files as singleton - just in case. But the only way the issue seems to go away is when i use the specific component inside material-ui.

Since the only working solution (At least for me, and for now) is using the @mui/. Is there anyway to make them go into some shared chunks? Meaning instead of 1 file per component, somehow create chunks of components based on.. something? ex. Amount of appearances throughout the build in each remote or..?

ScriptedAlchemy commented 1 year ago

Thanks for the detailed explanation, it totally makes sense.

Do i understand correctly, that the singleton indication should be the same for all remotes? Meaning that its not possible for one remote to use @mui/core and the other @mui/. Since it would mean that one remote has the "root" and the other has all the "sub packages", and it would create a "conflict". __webpack_share_scopes__.default also shows both of versions.

In any case, i've tried adding all dependencies from the material ui package.json files as singleton - just in case. But the only way the issue seems to go away is when i use the specific component inside material-ui.

Since the only working solution (At least for me, and for now) is using the @mui/. Is there anyway to make them go into some shared chunks? Meaning instead of 1 file per component, somehow create chunks of components based on.. something? ex. Amount of appearances throughout the build in each remote or..?

Singleton should be across all remotes. Since you don't want state to tear.

slavab89 commented 1 year ago

Just want to update here that i believe I've found the "culprit" in this whole material-ui situation.

tldr; Define shared with the following configuration. Look bellow for variations and explanations

'@material-ui/core': {
  singleton: true,
},
'@material-ui/core/styles': {
  singleton: true,
},
'@material-ui/core/utils': {
  singleton: true,
},
'@material-ui/lab': {
  singleton: true,
},
'@material-ui/styles': {
  singleton: true,
},
'@material-ui/pickers': {
  singleton: true,
}

What does this create? It will create 1 file for the whole material-ui/core package with 2 smaller files for the core/styles and core/utils which should be marked as singletons. Why? Because they seem to affect styling indirectly. Same goes for material-ui/styles itself.

Variation: If you want to split the material-ui/core components into 1 file per component, you could do:

'@material-ui/core/': { singleton: true },

But I did not want to go that way to not increase the overall file and bundlesize (While taking into account that users will have to download the whole core package even if they use only 1 component)

Same goes for the lab and/or icons package.

Note: In our config, we originally had the following section in Babel config (Now removed):

[
  'babel-plugin-import',
  {
    libraryName: '@material-ui/core',
    libraryDirectory: 'esm',
    camel2DashComponentName: false,
  },
  'core',
]

This is following the guide here. However, what this actually does, is converts the import systax in the output. So exposing the core package as a whole is not possible after this, and you're force to use the variation option that i've mentioned above.

I assume similar things can be done for v5.

ScriptedAlchemy commented 1 year ago

Send pr for example of material ui if you think it would be useful to have in my repo

wgolledge commented 1 year ago

Thanks for all of the detail @slavab89 and @ScriptedAlchemy.

We're experiencing the same issue in our app. No matter how much we try and massage the federation shared config we get duplicate styles being loaded in certain situations (when MUI is being instantiated twice). We have tried both the "catch-all" variation with @material-ui/ and targeting individual/all packages to no avail.

It looks as though the @material-ui/lab package is the culprit in our case (not that that is the concern of Webpack). Is there anything else you can suggest in terms of debugging? I've been using __webpack_share_scopes__ that confirms only 1 version of each package is getting loaded. Perhaps this is just an incompatibility with certain configurations of Material UI & Module Federation?

slavab89 commented 1 year ago

The main thing to take into account is that the imports are the same in the output. For example, if there is an import in the form of import Button from '@material-ui/core/Button' and another import { Button } from '@material-ui/core', those are different and Module Federation will load those components twice (As well as the styles probably)

The Lab library uses the '@material-ui/core/Button' format, so it kinda forces you to also use this format in your code.

How does your code import material components? If you use the @material-ui/ and print out __webpack_share_scopes__.default. Do you have both "material-ui/core" and the specific components in the list? If you do then you probably have more than 1 variation of import formats

What i did was use @material-ui/ (which solved the issue for us) and printed out __webpack_share_scopes__.default. Then I've put this as part of the "Shared" config, and started commenting out things to find the specific component that causes the issue.

correaricardo commented 1 year ago

@slavab89, Hello! Bro, can you show us how you've configured the shared config?

wgolledge commented 1 year ago

We were able to finally solve our conundrum by setting eager: true on all of our @material-ui and corresponding dependencies. This was the only thing we've tried that actually worked with our config and usage of MUI packages. This works for us currently as we only have a single host with multiple remotes, I figure this would no longer be a solution if we had an omnidirectional setup.

@slavab89 thanks for the recommendation. We actually have a linting rule set up to ensure that only named imports from @material-ui can be used and to block imports from @material-ui/core/styles (in-case anyone finds it helpful):

module.exports = {
  ...
  rules: {
      'no-restricted-imports': [
      'error',
      {
        patterns: ['@material-ui/core/*', '@material-ui/lab/*', '!@material-ui/core/styles'],
      },
    ],
  }
}
moalhaddar commented 1 year ago

Last updated 26th of june, 2023

Alright, so I've came across this problem in the weirdest way, and tried everything i've found in the web with no solution. Eventually, I've came up with a solution that works for me, which i'm not aware of it's drawbacks as of time of writing this comment. So buckle up.

My setup

I'm developing a shell app that contains all the shared state, themes etc, and part of the shared state and context is Material UI.

My shared packages setup is exactly this, across the shell/host and the remote containers:

{
    '@material-ui/core': {
        singleton: true,
        requiredVersion: deps['@material-ui/core'],
    },
    '@material-ui/pickers': {
        singleton: true,
        requiredVersion: deps['@material-ui/pickers'],
    },
    '@material-ui/lab': {
        singleton: true,
        requiredVersion: deps['@material-ui/lab'],
    },
    '@material-ui/styles': {
        singleton: true,
        requiredVersion: deps['@material-ui/styles'],
    },
}

What is the issue exactly?

When loading different components from any material ui package, they would dynamically apply some styles and append them inside the html <head /> tag, you can verify thiss yourself in when you inspect your page.

Example head styles:

<style data-jss data-meta="MuiButtonBase">/* ... */</style>
<style data-jss data-meta="MuiTouchRipple">/* ... */</style>
<style data-jss data-meta="MuiAutoComplete">/* ... */</style>

The problem happens is when you have multiple styles injected, containing the same className, but with different style values, and this is where the ordering of the injected styles matters, causing styles overriding, which breaks the UI.

For example, you might have a custom styling for the MuiButtonBase, and material ui has it's own MuiButtonBase styling.

If for some reason the styles were injected in this order:

MuiButtonBase (default material ui styles)
MuiButtonBase (your button base)

Then you wouldn't encounter any issues.

But if the styles happen to be in any different order that results in overriding your custom styles, then you'd end with some problems.

Example problematic ordering:

<!-- Default Material UI styles -->
<style data-jss data-meta="MuiButtonBase">/* ... */</style> 
<!-- Your Button Material UI styles -->
<style data-jss data-meta="MuiButtonBase">/* ... */</style>
<!-- THE PROBLEMATIC STYLES: Default Material UI styles, AGAIN -->
<style data-jss data-meta="MuiButtonBase">/* ... */</style>

This would override your custom styles, and break the UI and other components.

How did i discover the issue?

I modified the package name of my remote app in the package.json, which for some reason, made material-ui apply the jss styles in different order, i have no idea why or how, which is a different story, but yeah, that happened.

Why does this happen?

As @ScriptedAlchemy mentioned in this thread, material ui is composed of multiple packages, with some shared internal packages that maintain an internal state. Figuring out those packages is a rabbit hole and no one so far have posted a complete solution for this as of writing this comment. You could use the console.log(__webpack_share_scopes__.default); method as mentioned earlier in this thread to divide and conquer the packages one by one, until you find the problematic package that needs to be marked as shared, but I've noticed no matter what i do, there will be always a package breaking some other stuff (I'm looking at you, @material-ui/lab)

My solution

The solution to me was to isolate the classNames per micro-frontend, so no micro-frontend can inject jss styles that can override the others.

That means, each micro-frontend can have it's own jss styles party, so even if i have multiple instances of MuiButtonBase styles they won't override each other.

The solution is composed of two steps.

  1. First, you need to add a className prefix at the highest level of the component tree, usually before you render the imported micro-frontend component, for me, that was at the router level.
  2. You need to disable deterministic class names globally over the entire app, you need to do this in the entry point of your shell/host app.

To do that with material ui, you need to use the <StylesProvider /> component.

For step #1 the code looks something like this

import { StylesProvider, createGenerateClassName } from '@material-ui/core';
import { Route } from 'react-router-dom';

// Global Variable, independent from react state
const classNameGenerators: Record<string, any> = {}; 

const ComponentWithStyles = (props: any) => {
    // The prefix can be your micro-frontend key for example
    const classNamePrefix = 'prefix_here'
    let classNameGenerator;

    if (!classNameGenerators[classNamePrefix]) {
        classNameGenerator = createGenerateClassName({
            disableGlobal: true,
            productionPrefix: classNamePrefix, 
            seed: classNamePrefix,
        });
        classNameGenerators[classNamePrefix] = classNameGenerator;
    } else {
        classNameGenerator = classNameGenerators[classNamePrefix];
    }

    return (
        <StylesProvider generateClassName={classNameGenerator}>
            <FederatedComponent {...props} />
        </StylesProvider>
    );
};

Then inside every component, i don't care about what styles or packages they use, the styles will always be self contained, but also using the global theme. Note the usage of useMemo, since this is an HOC for the route render, we don't want to create a different generator for each route/micro-frontend to avoid unmounting the component when, for example, the route changes.

For step #2, in the entry point of your shell app, you will need to do create a simple classNameGenerator disabling the deterministic styles

import { StylesProvider, createGenerateClassName } from '@material-ui/core';

const classNameGenerator = createGenerateClassName({
    disableGlobal: true, // THIS IS VERY IMPORTANT!!
    productionPrefix: 'shell', 
    seed: 'shell',
});

// Where you define your providers

return (
    <StylesProvider generateClassName={classNameGenerator}>
        <ThemeProvider theme={theme}>{children}</ThemeProvider>
    </StylesProvider>
);

With this, you don't need to eagerly load the dependencies as mentioned in this comment

The result css

Assuming the prefix for the shell app is 'shell', and the micro frontend is 'admin', then the end result of the stylesheets would be this:

<style data-jss="" data-meta="MuiButtonBase">
    /* Note the 'shell' prefix  */
    .shell-MuiButtonBase-root-187 {
        color: inherit;
        border: 0;
        /* ... extra css  */
    }

</style>

<style data-jss="" data-meta="MuiButton">
    /* Note the 'Admin' prefix  */
    .Admin-MuiButton-root-22 {
        color: #343235;
        padding: 6px 16px;
        /* ... extra css  */
    }
</style>

Drawbacks of my solution

  1. You will have a stylesheet party at the <head/>, since each component will have it's own stylesheet even if they are duplicates.
  2. Non-deterministic classNames means it will be harder to debug your CSS issues.

I will update this if i come up with any problems using this method.