Repository: mui/material-ui (TypeScript)
Date: 2018-05-01 19:41:38+00:00
Task type: Bug Fix

Issue:
classes prop needs to be memoized
Maybe I am interpreting this wrong, but I believe that if I pass a `classes` prop to a component wrapped with `withStyles`, that component always re-renders with the parent, even if no props change.

```JS
const myClasses = { test: 'myClass' };

class MyComponent extends React.PureComponent {
  render() {
    return (
      <MySecondComponent classes={myClasses} />
    );
  }
}
```

I believe what's happening is when MySecondComponent is checked for updates, `withStyles` generates a _new object_ despite the fact that its `classes` prop has not changed.
https://github.com/mui-org/material-ui/blob/v1-beta/packages/material-ui/src/styles/withStyles.js#L234

This causes the SecondComponent to always re-render, despite its props not changing.


Issue discussion:
> In the future, we could also try to memoize the classes when users are providing a classes property.

Yep: #8142 
@CharlesStover I'm happy if we can investigate the memoization approach further. And merge the capability at the condition: the bundle size tradeoff is acceptable.
Could the whole `classesProp`/`renderedClasses` logic be moved to `getSnapshotBeforeUpdate` and only change if the `classes` prop changed?

```JS
constructor(props) {
  super(props);

  // logic that currently exists in render can be moved to this method
  this.classes = this.generateRenderedClasses();
}

getSnapshotBeforeUpdate(prevProps) {

  // Check if the classes prop's pointer has changed.
  if (this.props.classes !== prevProps.classes) {
    this.classes = this.generateRenderedClasses();
  }
}

render() {
  // ...
  return <Component {...more} classes={this.classes} ref={innerRef} {...other} />;
}
```
> Could the whole classesProp/renderedClasses logic be moved to getSnapshotBeforeUpdate and only change if the classes prop changed?

@CharlesStover No, the `sheetsManagerTheme.sheet.classes` value can change independently of the react lifecycle hooks. We use an event bus to propagate the theme update.
Hum, after more thought, yes, maybe. To try out :).
I think the `getSnapshotBeforeUpdate` method fires before any re-render. A re-render is currently necessary to get updated values from `sheetsManagerTheme.sheet.classes`, so getting those values from `getSnapshotBeforeUpdate` shouldn't change anything.
Please keep me in the loop on progress with this. I'm working on boosting performance of my project, and the Material UI package does not get along with the _why-did-you-update_ package. I've been having to just disable the MUI render checking altogether, but that leaves out a lot of performance issues that I am at fault for and should be fixing, simply because the component my mistakes land on are MUI.
> Please keep me in the loop on progress with this.

@CharlesStover I have no plan on working on this in the next few months. I would have already done it in the pull-request I have linked (#8142) if I thought it was very important. Most of the time, people are providing a new object to the `classes` property, breaking the memoization logic anyway.
Yes, most the time people use React incorrectly and cause re-renders when they shouldn't, as the concept of objects as pointers goes over a lot of junior devs' heads. I don't think that justifies people who know better doing the same or making it impossible to use this package as React is designed to be used.
If I can PR this, can it get merged?
> If I can PR this, can it get merged?

@CharlesStover As long as the logic is correct, the only counter argument I have was expressed earlier :)

> And merge the capability at the condition: **the bundle size tradeoff is acceptable**.

Solution patch:
diff --git a/.size-limit b/.size-limit
--- a/.size-limit
+++ b/.size-limit
@@ -9,7 +9,7 @@
     "name": "The size of all the modules of material-ui.",
     "webpack": true,
     "path": "packages/material-ui/build/index.js",
-    "limit": "101.3 KB"
+    "limit": "101.4 KB"
   },
   {
     "name": "The main bundle of the docs",
diff --git a/packages/material-ui/src/styles/withStyles.js b/packages/material-ui/src/styles/withStyles.js
--- a/packages/material-ui/src/styles/withStyles.js
+++ b/packages/material-ui/src/styles/withStyles.js
@@ -93,6 +93,15 @@ const withStyles = (stylesOrCreator, options = {}) => Component => {
       this.theme = listenToTheme ? themeListener.initial(context) || getDefaultTheme() : noopTheme;
 
       this.attach(this.theme);
+
+      this.cacheClasses = {
+        // Cache for the finalized classes value.
+        value: null,
+        // Cache for the last used classes prop pointer.
+        lastProp: null,
+        // Cache for the last used rendered classes pointer.
+        lastJSS: {},
+      };
     }
 
     state = {};
@@ -135,6 +144,69 @@ const withStyles = (stylesOrCreator, options = {}) => Component => {
       }
     }
 
+    getClasses() {
+      // Tracks if either the rendered classes or classes prop has changed,
+      // requiring the generation of a new finalized classes object.
+      let generate = false;
+
+      if (!this.disableStylesGeneration) {
+        const sheetManager = this.sheetsManager.get(this.stylesCreatorSaved);
+        const sheetsManagerTheme = sheetManager.get(this.theme);
+        if (sheetsManagerTheme.sheet.classes !== this.cacheClasses.lastJSS) {
+          this.cacheClasses.lastJSS = sheetsManagerTheme.sheet.classes;
+          generate = true;
+        }
+      }
+
+      if (this.props.classes !== this.cacheClasses.lastProp) {
+        this.cacheClasses.lastProp = this.props.classes;
+        generate = true;
+      }
+
+      if (generate) {
+        if (this.props.classes) {
+          this.cacheClasses.value = {
+            ...this.cacheClasses.lastJSS,
+            ...Object.keys(this.props.classes).reduce((accumulator, key) => {
+              warning(
+                this.cacheClasses.lastJSS[key] || this.disableStylesGeneration,
+                [
+                  `Material-UI: the key \`${key}\` ` +
+                    `provided to the classes property is not implemented in ${getDisplayName(
+                      Component,
+                    )}.`,
+                  `You can only override one of the following: ${Object.keys(
+                    this.cacheClasses.lastJSS,
+                  ).join(',')}`,
+                ].join('\n'),
+              );
+
+              warning(
+                !this.props.classes[key] || typeof this.props.classes[key] === 'string',
+                [
+                  `Material-UI: the key \`${key}\` ` +
+                    `provided to the classes property is not valid for ${getDisplayName(
+                      Component,
+                    )}.`,
+                  `You need to provide a non empty string instead of: ${this.props.classes[key]}.`,
+                ].join('\n'),
+              );
+
+              if (this.props.classes[key]) {
+                accumulator[key] = `${this.cacheClasses.lastJSS[key]} ${this.props.classes[key]}`;
+              }
+
+              return accumulator;
+            }, {}),
+          };
+        } else {
+          this.cacheClasses.value = this.cacheClasses.lastJSS;
+        }
+      }
+
+      return this.cacheClasses.value;
+    }
+
     attach(theme) {
       if (this.disableStylesGeneration) {
         return;
@@ -219,53 +291,7 @@ const withStyles = (stylesOrCreator, options = {}) => Component => {
     unsubscribeId = null;
 
     render() {
-      const { classes: classesProp, innerRef, ...other } = this.props;
-
-      let classes;
-      let renderedClasses = {};
-
-      if (!this.disableStylesGeneration) {
-        const sheetManager = this.sheetsManager.get(this.stylesCreatorSaved);
-        const sheetsManagerTheme = sheetManager.get(this.theme);
-        renderedClasses = sheetsManagerTheme.sheet.classes;
-      }
-
-      if (classesProp) {
-        classes = {
-          ...renderedClasses,
-          ...Object.keys(classesProp).reduce((accumulator, key) => {
-            warning(
-              renderedClasses[key] || this.disableStylesGeneration,
-              [
-                `Material-UI: the key \`${key}\` ` +
-                  `provided to the classes property is not implemented in ${getDisplayName(
-                    Component,
-                  )}.`,
-                `You can only override one of the following: ${Object.keys(renderedClasses).join(
-                  ',',
-                )}`,
-              ].join('\n'),
-            );
-
-            warning(
-              !classesProp[key] || typeof classesProp[key] === 'string',
-              [
-                `Material-UI: the key \`${key}\` ` +
-                  `provided to the classes property is not valid for ${getDisplayName(Component)}.`,
-                `You need to provide a non empty string instead of: ${classesProp[key]}.`,
-              ].join('\n'),
-            );
-
-            if (classesProp[key]) {
-              accumulator[key] = `${renderedClasses[key]} ${classesProp[key]}`;
-            }
-
-            return accumulator;
-          }, {}),
-        };
-      } else {
-        classes = renderedClasses;
-      }
+      const { classes, innerRef, ...other } = this.props;
 
       const more = getThemeProps({ theme: this.theme, name });
 
@@ -275,7 +301,7 @@ const withStyles = (stylesOrCreator, options = {}) => Component => {
         more.theme = this.theme;
       }
 
-      return <Component {...more} classes={classes} ref={innerRef} {...other} />;
+      return <Component {...more} classes={this.getClasses()} ref={innerRef} {...other} />;
     }
   }
 


Test patch:
diff --git a/packages/material-ui/src/styles/withStyles.test.js b/packages/material-ui/src/styles/withStyles.test.js
--- a/packages/material-ui/src/styles/withStyles.test.js
+++ b/packages/material-ui/src/styles/withStyles.test.js
@@ -65,7 +65,7 @@ describe('withStyles', () => {
 
       it('should ignore undefined property', () => {
         const wrapper = shallow(<StyledComponent1 classes={{ root: undefined }} />);
-        assert.deepEqual(wrapper.props().classes, { root: `${classes.root}` });
+        assert.deepEqual(wrapper.props().classes, { root: classes.root });
         assert.strictEqual(consoleErrorMock.callCount(), 0);
       });
 
@@ -90,21 +90,50 @@ describe('withStyles', () => {
           /Material-UI: the key `root` provided to the classes property is not valid/,
         );
       });
+    });
 
-      it('should recycle the object between two render if possible', () => {
+    describe('prop: innerRef', () => {
+      it('should provide a ref on the inner component', () => {
+        const handleRef = spy();
+        mount(<StyledComponent1 innerRef={handleRef} />);
+        assert.strictEqual(handleRef.callCount, 1);
+      });
+    });
+
+    describe('cache', () => {
+      it('should recycle with no classes property', () => {
         const wrapper = mount(<StyledComponent1 />);
         const classes1 = wrapper.find(Empty).props().classes;
         wrapper.update();
         const classes2 = wrapper.find(Empty).props().classes;
         assert.strictEqual(classes1, classes2);
       });
-    });
 
-    describe('prop: innerRef', () => {
-      it('should provide a ref on the inner component', () => {
-        const handleRef = spy();
-        mount(<StyledComponent1 innerRef={handleRef} />);
-        assert.strictEqual(handleRef.callCount, 1);
+      it('should recycle even when a classes property is provided', () => {
+        const inputClasses = { root: 'foo' };
+        const wrapper = mount(<StyledComponent1 classes={inputClasses} />);
+        const classes1 = wrapper.find(Empty).props().classes;
+        wrapper.setProps({
+          classes: inputClasses,
+        });
+        const classes2 = wrapper.find(Empty).props().classes;
+        assert.strictEqual(classes1, classes2);
+      });
+
+      it('should invalidate the cache', () => {
+        const wrapper = mount(<StyledComponent1 classes={{ root: 'foo' }} />);
+        const classes1 = wrapper.find(Empty).props().classes;
+        assert.deepEqual(classes1, {
+          root: `${classes.root} foo`,
+        });
+        wrapper.setProps({
+          classes: { root: 'bar' },
+        });
+        const classes2 = wrapper.find(Empty).props().classes;
+        assert.notStrictEqual(classes1, classes2);
+        assert.deepEqual(classes2, {
+          root: `${classes.root} bar`,
+        });
       });
     });
   });