Repository: mui/material-ui (TypeScript)
Date: 2019-04-04 18:25:13+00:00
Task type: Bug Fix

Issue:
Nested MuiThemeProviders cause reinsertion of styles into DOM
If you nest multiple MuiThemeProviders, after each render the style elements of the children get reinserted into DOM. This may lead to noticeable performance issues (when rendering on each keystroke etc.).

![out](https://user-images.githubusercontent.com/1280734/55517952-745ca580-5672-11e9-86a6-2021a0938bfd.gif)

Bug **is present** in the new @material-ui/styles implementations, I tested both latest 4.0.0-alpha.6 and also 3.0.0-alpha.10 (with bootstrap.js & `install()`).

Bug **is not present** when using the old implementation of default @material-ui/core/styles. (You can try downgrade material-ui/core to 3.x.x version in the attached codesandbox)

<!-- Checked checkbox should look like this: [x] -->
- [x] This is not a v0.x issue. <!-- (v0.x is no longer maintained) -->
- [x] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Expected Behavior 🤔

DOM should be updated only in those parts that were actually changed - styles should not be rerendered if the theme was not changed.

## Current Behavior 😯
<!---
    Describe what happens instead of the expected behavior.
-->

Styles get reattached to the DOM even if it is not necessary.

## Steps to Reproduce 🕹

Link: https://codesandbox.io/s/vymjokm8l3?fontsize=14&module=%2Fdemo.js

1. Open dev-tools, find the iframe with the project
2. Click the green button, the number value gets updated, however you can see in dev-tools that `head` > `style` tags are also being all updated, _which is unnecessary_.
3. Comment one of the MuiThemeProviders, so that only one remains
4. Click the button. In dev-tools only the number value is updated - as it should be.

## Context 🔦
<!---
    What are you trying to accomplish? How has this issue affected you?
    Providing context helps us come up with a solution that is most useful in the real world.
-->

I'm creating a library of reusable "GUI settings screens" that have their own defined theme - primary and secondary colors, that are usually different than colors used in the host app. The host app with it's top-level ThemeProvider is fast, however as soon as you open the settings screen with It's nested ThemeProvider, every input (due to this bug) causes significant changes to DOM and leads to noticeable delay.

As soon as you remove the inner ThemeProvider or use the settings dialogs separately, everything is fast, as it should be.

## Your Environment 🌎
<!---
    Include as many relevant details about the environment with which you experienced the bug.
    If you encounter issues with typescript please include version and tsconfig.
-->

| Tech         | Version |
|--------------|---------|
| @material-ui/core  | v3.9.3 with /styles v3.0.0-alpha.10,         |
 |                               | v4.0.0-alpha.6 with /styles v4.0.0-alpha.6 |
| React        | 16.8.6    |
| Browser      | Chromium 73.0.3683.86 |


Issue discussion:
Our theme providers are not memoized which is probably the reason for the flashes. I would prefer them memoized as well but these can cause subtle bugs if you mutate the theme object directly. 

I think you can fix this by wrapping the theme provider in `React.memo` and use the memoized version instead.
Wow, thank you for your super-fast response!

> if you mutate the theme object directly

Someone actually does that? :smile: What does the use-case look like?

I tried wrapping the MuiThemeProvider component with `React.memo`, but the problem seems to persist:
https://codesandbox.io/s/5mwq48pp4l

> > if you mutate the theme object directly
> 
> Someone actually does that?  What does the use-case look like?

Just a guess. The opposite case would be if you always recreate the theme on render and memoization doesn't do anything.

But memo won't help you here since the children always change so the parent rerenders as well. I can't really offer you any solution here to your specific problem since it depends on your use case but you have to split up the the components the act as providers and the actual consumers. Essentially move the state into the bottom theme provider. That way you don't rerender the whole tree.
@jhrdina It's a good analysis. It's working fine in `v3.9.3` because we have this prune logic:
https://github.com/mui-org/material-ui/blob/ebed7922fc97331519f507768572a068edb8494e/packages/material-ui/src/styles/MuiThemeProvider.js#L58-L60

We could apply the same strategy in v4. Can you try this diff?
```diff
--- a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js
+++ b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js
@@ -46,7 +46,11 @@ function ThemeProvider(props) {
     ].join('\n'),
   );

-  const theme = outerTheme === null ? localTheme : mergeOuterLocalTheme(outerTheme, localTheme);
+  const theme = React.useMemo(
+    () => (outerTheme === null ? localTheme : mergeOuterLocalTheme(outerTheme, localTheme)),
+    [localTheme, outerTheme],
+  );
+
   return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
 }
```

@eps1lon Does it sound acceptable to you?
Sounds good.
@oliviertassinari I've just tried your patch in the v4.0.0-alpha.6 and it seems to work perfectly. :tada: Thank you very much!

@eps1lon 
> Essentially move the state into the bottom theme provider

Unfortunately, this is not a viable solution for me, a just have to pass the state using props from above the inner MuiThemeProvider. If passing larger state is not common, still I guess people may want to pass at least some props. And a change in any one of them would lead to styles reattaching...
@jhrdina I would need to see a specific example for this. In your example the ThemeProviders have no dependency on the state so it would be possible to move the providers to the top.

I know this can be hard to extract the exact use case and not some over abstraction but it's important for library maintainers to understand the actual use case. Otherwise we might over abstract and account for some use cases that never actually happen.
@eps1lon I completely understand, don't worry! I simply wanted to provide the minimal repro example, I didn't want to overwhelm you and waste your time.

Even though **I find this bug to be solved** by the proposed patch, I will try to create at least a pseudo-code for more real-world scenario:

```javascript
// Reusable P2P Settings Screens library
const ReusableP2PSettingsScreens = ({libraryState, dispatch}) =>
  <ThemeProvider theme={libraryTheme}>
    {switch (libraryState.route) {
    | "screen1" => <SettingsScreen1 state={libraryState.screen1} dispatch={dispatch}/>
    | "screen2" => <SettingsScreen2 state={libraryState.screen2} dispatch={dispatch}/>
    }}
  </ThemeProvider>

// Host app
const HostApp = ({globalState, dispatch}) =>
  <ThemeProvider theme={hostTheme}>
    {switch(globalState.route) {
    | "editor" =>  <EditorScreen globalState.editorState/>
    | "settings" =>
      <ReusableP2PSettingsScreens
        libraryState={globalState.settingsState}
        dispatch={x => dispatch(settingsAction(x))}
      />
    }}
  </ThemeProvider>
```

To be 100% honest, I use your MUI library in ReasonReact in ReasonML programming language via bs-material-ui bindings, managing global state using custom fork of The Elm Architecture ported 1:1 to Reason. You cannot get more exotic than that :smile: Still I think the situation above is relevant even for normal people, especially those infected by Redux :smile:

Thank you very much for your time and your help!
@jhrdina Do you want to submit a pull request with the patch? :) 
OK, I will try...

Solution patch:
diff --git a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js
--- a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js
+++ b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js
@@ -46,7 +46,10 @@ function ThemeProvider(props) {
     ].join('\n'),
   );
 
-  const theme = outerTheme === null ? localTheme : mergeOuterLocalTheme(outerTheme, localTheme);
+  const theme = React.useMemo(
+    () => (outerTheme === null ? localTheme : mergeOuterLocalTheme(outerTheme, localTheme)),
+    [localTheme, outerTheme],
+  );
   return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
 }
 


Test patch:
diff --git a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js
--- a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js
+++ b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js
@@ -53,6 +53,42 @@ describe('ThemeProvider', () => {
     assert.strictEqual(wrapper.text(), 'foobar');
   });
 
+  it('should memoize the merged output', () => {
+    const themes = [];
+
+    function Test() {
+      const theme = useTheme();
+      themes.push(theme);
+
+      return (
+        <span>
+          {theme.foo}
+          {theme.bar}
+        </span>
+      );
+    }
+    const MemoTest = React.memo(Test);
+
+    const outerTheme = { bar: 'bar' };
+    const innerTheme = { foo: 'foo' };
+
+    function Container() {
+      return (
+        <ThemeProvider theme={outerTheme}>
+          <ThemeProvider theme={innerTheme}>
+            <MemoTest />
+          </ThemeProvider>
+        </ThemeProvider>
+      );
+    }
+
+    const wrapper = mount(<Container />);
+    assert.strictEqual(wrapper.text(), 'foobar');
+    wrapper.setProps({});
+    assert.strictEqual(wrapper.text(), 'foobar');
+    assert.strictEqual(themes[0], themes[1]);
+  });
+
   describe('warnings', () => {
     beforeEach(() => {
       consoleErrorMock.spy();