Repository: mui/material-ui (TypeScript)
Date: 2019-09-14 06:42:12+00:00
Task type: Feature

Issue:
[core] Warn when changing between controlled and uncontrolled
<!-- Provide a general summary of the feature in the Title above -->

<!--
  Thank you very much for contributing to Material-UI by creating an issue! ❤️
  To avoid duplicate issues we ask you to check off the following list.
-->

<!-- Checked checkbox should look like this: [x] -->

- [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.

## Summary 💡

We have a couple of components that implement a controlled and uncontrolled mode, not all of them warn when switching between the two modes.

- InputBase: yes
- SwitchBase: yes
- RadioGroup: yes
- Slider: no
- ExpansionPanel: no
- Tooltip: no

## Examples 🌈

We could solve the problem with:

```diff
diff --git a/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js b/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js
index 27406be72c..7cb65696d5 100644
--- a/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js
+++ b/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import warning from 'warning';
 import clsx from 'clsx';
 import { chainPropTypes } from '@material-ui/utils';
 import Collapse from '../Collapse';
@@ -96,6 +97,21 @@ const ExpansionPanel = React.forwardRef(function ExpansionPanel(props, ref) {
   const [expandedState, setExpandedState] = React.useState(defaultExpanded);
   const expanded = isControlled ? expandedProp : expandedState;

+  React.useEffect(() => {
+    warning(
+      isControlled === (expandedProp != null),
+      [
+        `Material-UI: A component is changing ${
+          isControlled ? 'a ' : 'an un'
+        }controlled ExpansionPanel to be ${isControlled ? 'un' : ''}controlled.`,
+        'Input elements should not switch from uncontrolled to controlled (or vice versa).',
+        'Decide between using a controlled or uncontrolled ExpansionPanel ' +
+          'element for the lifetime of the component.',
+        'More info: https://fb.me/react-controlled-components',
+      ].join('\n'),
+    );
+  }, [expandedProp, isControlled]);
+
   const handleChange = event => {
     if (!isControlled) {
       setExpandedState(!expanded);
```

## Motivation 🔦

<!--
  What are you trying to accomplish? How has the lack of this feature affected you?
  Providing context helps us come up with a solution that is most useful in the real world.
-->

For some time, I thought that #17396 was an issue with a switch between the controlled and uncontrolled mode (turns out, it's not, it's a generic React question). 


Issue discussion:
Is this up for grabs? I would like to contribute on this. 🙂
Yes, we would love to see your contribution :D. 

Solution patch:
diff --git a/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js b/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js
--- a/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js
+++ b/packages/material-ui/src/ExpansionPanel/ExpansionPanel.js
@@ -96,6 +96,25 @@ const ExpansionPanel = React.forwardRef(function ExpansionPanel(props, ref) {
   const [expandedState, setExpandedState] = React.useState(defaultExpanded);
   const expanded = isControlled ? expandedProp : expandedState;
 
+  if (process.env.NODE_ENV !== 'production') {
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    React.useEffect(() => {
+      if (isControlled !== (expandedProp != null)) {
+        console.error(
+          [
+            `Material-UI: A component is changing ${
+              isControlled ? 'a ' : 'an un'
+            }controlled ExpansionPanel to be ${isControlled ? 'un' : ''}controlled.`,
+            'Elements should not switch from uncontrolled to controlled (or vice versa).',
+            'Decide between using a controlled or uncontrolled ExpansionPanel ' +
+              'element for the lifetime of the component.',
+            'More info: https://fb.me/react-controlled-components',
+          ].join('\n'),
+        );
+      }
+    }, [expandedProp, isControlled]);
+  }
+
   const handleChange = event => {
     if (!isControlled) {
       setExpandedState(!expanded);
diff --git a/packages/material-ui/src/RadioGroup/RadioGroup.js b/packages/material-ui/src/RadioGroup/RadioGroup.js
--- a/packages/material-ui/src/RadioGroup/RadioGroup.js
+++ b/packages/material-ui/src/RadioGroup/RadioGroup.js
@@ -42,7 +42,7 @@ const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
             `Material-UI: A component is changing ${
               isControlled ? 'a ' : 'an un'
             }controlled RadioGroup to be ${isControlled ? 'un' : ''}controlled.`,
-            'Input elements should not switch from uncontrolled to controlled (or vice versa).',
+            'Elements should not switch from uncontrolled to controlled (or vice versa).',
             'Decide between using a controlled or uncontrolled RadioGroup ' +
               'element for the lifetime of the component.',
             'More info: https://fb.me/react-controlled-components',
diff --git a/packages/material-ui/src/Slider/Slider.js b/packages/material-ui/src/Slider/Slider.js
--- a/packages/material-ui/src/Slider/Slider.js
+++ b/packages/material-ui/src/Slider/Slider.js
@@ -618,6 +618,25 @@ const Slider = React.forwardRef(function Slider(props, ref) {
     };
   }, [disabled, handleMouseEnter, handleTouchEnd, handleTouchMove, handleTouchStart]);
 
+  if (process.env.NODE_ENV !== 'production') {
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    React.useEffect(() => {
+      if (isControlled !== (valueProp != null)) {
+        console.error(
+          [
+            `Material-UI: A component is changing ${
+              isControlled ? 'a ' : 'an un'
+            }controlled Slider to be ${isControlled ? 'un' : ''}controlled.`,
+            'Elements should not switch from uncontrolled to controlled (or vice versa).',
+            'Decide between using a controlled or uncontrolled Slider ' +
+              'element for the lifetime of the component.',
+            'More info: https://fb.me/react-controlled-components',
+          ].join('\n'),
+        );
+      }
+    }, [valueProp, isControlled]);
+  }
+
   const handleMouseDown = useEventCallback(event => {
     if (onMouseDown) {
       onMouseDown(event);
diff --git a/packages/material-ui/src/Tooltip/Tooltip.js b/packages/material-ui/src/Tooltip/Tooltip.js
--- a/packages/material-ui/src/Tooltip/Tooltip.js
+++ b/packages/material-ui/src/Tooltip/Tooltip.js
@@ -166,6 +166,25 @@ function Tooltip(props) {
     };
   }, []);
 
+  if (process.env.NODE_ENV !== 'production') {
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    React.useEffect(() => {
+      if (isControlled !== (openProp != null)) {
+        console.error(
+          [
+            `Material-UI: A component is changing ${
+              isControlled ? 'a ' : 'an un'
+            }controlled Tooltip to be ${isControlled ? 'un' : ''}controlled.`,
+            'Elements should not switch from uncontrolled to controlled (or vice versa).',
+            'Decide between using a controlled or uncontrolled Tooltip ' +
+              'element for the lifetime of the component.',
+            'More info: https://fb.me/react-controlled-components',
+          ].join('\n'),
+        );
+      }
+    }, [openProp, isControlled]);
+  }
+
   const handleOpen = event => {
     // The mouseover event will trigger for every nested element in the tooltip.
     // We can skip rerendering when the tooltip is already open.


Test patch:
diff --git a/packages/material-ui/src/ExpansionPanel/ExpansionPanel.test.js b/packages/material-ui/src/ExpansionPanel/ExpansionPanel.test.js
--- a/packages/material-ui/src/ExpansionPanel/ExpansionPanel.test.js
+++ b/packages/material-ui/src/ExpansionPanel/ExpansionPanel.test.js
@@ -38,7 +38,6 @@ describe('<ExpansionPanel />', () => {
     const root = wrapper.find(`.${classes.root}`).first();
     assert.strictEqual(root.type(), Paper);
     assert.strictEqual(root.props().square, false);
-    wrapper.setProps({ expanded: true });
     assert.strictEqual(root.hasClass(classes.expanded), false, 'uncontrolled');
   });
 
@@ -188,4 +187,34 @@ describe('<ExpansionPanel />', () => {
       );
     });
   });
+
+  describe('warnings', () => {
+    beforeEach(() => {
+      consoleErrorMock.spy();
+    });
+
+    afterEach(() => {
+      consoleErrorMock.reset();
+    });
+
+    it('should warn when switching from controlled to uncontrolled', () => {
+      const wrapper = mount(<ExpansionPanel expanded>{minimalChildren}</ExpansionPanel>);
+
+      wrapper.setProps({ expanded: undefined });
+      assert.include(
+        consoleErrorMock.args()[0][0],
+        'A component is changing a controlled ExpansionPanel to be uncontrolled.',
+      );
+    });
+
+    it('should warn when switching between uncontrolled to controlled', () => {
+      const wrapper = mount(<ExpansionPanel>{minimalChildren}</ExpansionPanel>);
+
+      wrapper.setProps({ expanded: true });
+      assert.include(
+        consoleErrorMock.args()[0][0],
+        'A component is changing an uncontrolled ExpansionPanel to be controlled.',
+      );
+    });
+  });
 });
diff --git a/packages/material-ui/src/Slider/Slider.test.js b/packages/material-ui/src/Slider/Slider.test.js
--- a/packages/material-ui/src/Slider/Slider.test.js
+++ b/packages/material-ui/src/Slider/Slider.test.js
@@ -476,6 +476,24 @@ describe('<Slider />', () => {
         'you need to use the `getAriaLabel` prop instead of',
       );
     });
+
+    it('should warn when switching from controlled to uncontrolled', () => {
+      const { setProps } = render(<Slider value={[20, 50]} />);
+
+      setProps({ value: undefined });
+      expect(consoleErrorMock.args()[0][0]).to.include(
+        'A component is changing a controlled Slider to be uncontrolled.',
+      );
+    });
+
+    it('should warn when switching between uncontrolled to controlled', () => {
+      const { setProps } = render(<Slider />);
+
+      setProps({ value: [20, 50] });
+      expect(consoleErrorMock.args()[0][0]).to.include(
+        'A component is changing an uncontrolled Slider to be controlled.',
+      );
+    });
   });
 
   it('should support getAriaValueText', () => {
diff --git a/packages/material-ui/src/Tooltip/Tooltip.test.js b/packages/material-ui/src/Tooltip/Tooltip.test.js
--- a/packages/material-ui/src/Tooltip/Tooltip.test.js
+++ b/packages/material-ui/src/Tooltip/Tooltip.test.js
@@ -382,4 +382,24 @@ describe('<Tooltip />', () => {
       assert.strictEqual(wrapper.find('[role="tooltip"]').exists(), true);
     });
   });
+
+  describe('warnings', () => {
+    beforeEach(() => {
+      consoleErrorMock.spy();
+    });
+
+    afterEach(() => {
+      consoleErrorMock.reset();
+    });
+
+    it('should warn when switching between uncontrolled to controlled', () => {
+      const wrapper = mount(<Tooltip {...defaultProps} />);
+
+      wrapper.setProps({ open: true });
+      assert.include(
+        consoleErrorMock.args()[0][0],
+        'A component is changing an uncontrolled Tooltip to be controlled.',
+      );
+    });
+  });
 });