Repository: mui/material-ui (TypeScript)
Date: 2019-06-27 15:33:05+00:00
Task type: Bug Fix

Issue:
How to use react-stripe-js?
When changing any Stripe input the InputBase.js produces an error in the dirtyCheck when trying to access the value of either the inputRef.current or target.value: `Cannot read property 'value' of undefined`

<!--
    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] 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 🤔
Input should update normally

## Current Behavior 😯
Produces error and prevents using input

## Steps to Reproduce 🕹
<!---
    Provide a link to a live example (you can use codesandbox.io) and an unambiguous set of steps to reproduce this bug.
    Include code to reproduce, if relevant (which it most likely is).

    This codesandbox.io template _may_ be a good starting point:
    https://codesandbox.io/s/github/mui-org/material-ui/tree/master/examples/create-react-app

    If you're using typescript a better starting point would be
    https://codesandbox.io/s/github/mui-org/material-ui/tree/master/examples/create-react-app-with-typescript

    If YOU DO NOT take time to provide a codesandbox.io reproduction, should the COMMUNITY take time to help you?

-->
Link: https://codesandbox.io/s/createreactapp-1e4fe

1. Enter a number
2. Check the console for error, it should say `Cannot read property 'value' of undefined`

## Context 🔦
Error causes inability to proceed in payment screen

## 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  | v4.0.2  |
| React        |     v16.8.6    |
| Browser      |    Chrome Version 74.0.3729.169     |
| TypeScript   |    N/A     |
| etc.         |         |


![Screen Shot 2019-06-03 at 1 48 28 PM](https://user-images.githubusercontent.com/20974159/58826531-e4ee5700-860e-11e9-9d76-a522a6de9434.png)
![Screen Shot 2019-06-03 at 1 48 41 PM](https://user-images.githubusercontent.com/20974159/58826532-e4ee5700-860e-11e9-8166-7a361d64b959.png)





Issue discussion:
@gvetsa Thanks for the report. It seems https://gist.github.com/lfalke/1c5e7168424c8b2a65dcfba425fcc310 is no longer working. This Stripe React element doesn't play very nicely with the idiomatic React APIs, they have no ref forwarding logic nor a standard event API. I would propose the following fix:
```diff
diff --git a/packages/material-ui/src/InputBase/InputBase.js b/packages/material-ui/src/InputBase/InputBase.js
index ec78e68bb..d3cbb3e63 100644
--- a/packages/material-ui/src/InputBase/InputBase.js
+++ b/packages/material-ui/src/InputBase/InputBase.js
@@ -283,9 +283,14 @@ const InputBase = React.forwardRef(function InputBase(props, ref) {

   const handleChange = (event, ...args) => {
     if (!isControlled) {
-      checkDirty({
-        value: (event.target || inputRef.current).value,
-      });
+      const element = event.target || inputRef.current;
+      checkDirty(
+        element
+          ? {
+              value: element.value,
+            }
+          : null,
+      );
     }

```

Solution patch:
diff --git a/docs/src/modules/utils/parseMarkdown.js b/docs/src/modules/utils/parseMarkdown.js
--- a/docs/src/modules/utils/parseMarkdown.js
+++ b/docs/src/modules/utils/parseMarkdown.js
@@ -37,7 +37,7 @@ export const demoRegexp = /^"demo": "(.*)"/;
 export function getContents(markdown) {
   return markdown
     .replace(headerRegExp, '') // Remove header information
-    .split(/^{{|}}$/gm) // Split markdown into an array, separating demos
+    .split(/^{{("demo":[^}]*)}}$/gm) // Split markdown into an array, separating demos
     .filter(content => !emptyRegExp.test(content)); // Remove empty lines
 }
 
diff --git a/docs/src/pages/components/checkboxes/checkboxes.md b/docs/src/pages/components/checkboxes/checkboxes.md
--- a/docs/src/pages/components/checkboxes/checkboxes.md
+++ b/docs/src/pages/components/checkboxes/checkboxes.md
@@ -41,7 +41,7 @@ In this case, you can apply the additional attribute (e.g. `aria-label`, `aria-l
 ```jsx
 <Checkbox
   value="checkedA"
-  inputProps={{ 'aria-label': 'Checkbox A' } }
+  inputProps={{ 'aria-label': 'Checkbox A' }}
 />
 ```
 
diff --git a/docs/src/pages/components/radio-buttons/radio-buttons.md b/docs/src/pages/components/radio-buttons/radio-buttons.md
--- a/docs/src/pages/components/radio-buttons/radio-buttons.md
+++ b/docs/src/pages/components/radio-buttons/radio-buttons.md
@@ -38,7 +38,7 @@ In this case, you can apply the additional attribute (e.g. `aria-label`, `aria-l
 ```jsx
 <RadioButton
   value="radioA"
-  inputProps={{ 'aria-label': 'Radio A' } }
+  inputProps={{ 'aria-label': 'Radio A' }}
 />
 ```
 
diff --git a/docs/src/pages/components/switches/switches.md b/docs/src/pages/components/switches/switches.md
--- a/docs/src/pages/components/switches/switches.md
+++ b/docs/src/pages/components/switches/switches.md
@@ -49,6 +49,6 @@ In this case, you can apply the additional attribute (e.g. `aria-label`, `aria-l
 ```jsx
 <Switch
   value="checkedA"
-  inputProps={{ 'aria-label': 'Switch A' } }
+  inputProps={{ 'aria-label': 'Switch A' }}
 />
 ```
diff --git a/docs/src/pages/components/text-fields/text-fields.md b/docs/src/pages/components/text-fields/text-fields.md
--- a/docs/src/pages/components/text-fields/text-fields.md
+++ b/docs/src/pages/components/text-fields/text-fields.md
@@ -109,17 +109,50 @@ or
 <InputLabel shrink>Count</InputLabel>
 ```
 
-## Formatted inputs
+## Integration with 3rd party input libraries
 
 You can use third-party libraries to format an input.
 You have to provide a custom implementation of the `<input>` element with the `inputComponent` property.
-The provided input component should handle the `inputRef` property.
-The property should be called with a value implementing the [`HTMLInputElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement) interface.
 
-The following demo uses the [react-text-mask](https://github.com/text-mask/text-mask) and [react-number-format](https://github.com/s-yadav/react-number-format) libraries.
+The following demo uses the [react-text-mask](https://github.com/text-mask/text-mask) and [react-number-format](https://github.com/s-yadav/react-number-format) libraries. The same concept could be applied to [e.g. react-stripe-element](https://github.com/mui-org/material-ui/issues/16037).
 
 {{"demo": "pages/components/text-fields/FormattedInputs.js"}}
 
+The provided input component should handle the `inputRef` property.
+The property should be called with a value that implements the following interface:
+
+```ts
+interface InputElement {
+  focus(): void;
+  value?: string;
+}
+```
+
+```jsx
+function MyInputComponent(props) {
+  const { component: Component, inputRef, ...other } = props;
+
+  // implement `InputElement` interface
+  React.useImperativeHandle(inputRef, () => ({
+    focus: () => {
+      // logic to focus the rendered component from 3rd party belongs here
+    },
+    // hiding the value e.g. react-stripe-elements
+  }));
+
+  // `Component` will be your `SomeThirdPartyComponent` from below
+  return <Component {...other} />;
+}
+
+// usage
+<TextField
+  InputProps={{
+    inputComponent: MyInputComponent,
+    inputProps: { component: SomeThirdPartyComponent },
+  }}
+/>;
+```
+
 ## Accessibility
 
 In order for the text field to be accessible, **the input should be linked to the label and the helper text**. The underlying DOM nodes should have this structure.
diff --git a/docs/src/pages/customization/components/components.md b/docs/src/pages/customization/components/components.md
--- a/docs/src/pages/customization/components/components.md
+++ b/docs/src/pages/customization/components/components.md
@@ -190,7 +190,7 @@ compiles to:
   classes={{
     root: classes.root, // class name, e.g. `root-x`
     disabled: classes.disabled, // class name, e.g. `disabled-x`
-  } }
+  }}
 >
 ```
 
diff --git a/docs/src/pages/guides/migration-v3/migration-v3.md b/docs/src/pages/guides/migration-v3/migration-v3.md
--- a/docs/src/pages/guides/migration-v3/migration-v3.md
+++ b/docs/src/pages/guides/migration-v3/migration-v3.md
@@ -383,8 +383,8 @@ You should be able to move the custom styles to the `root` class key.
 
   ```diff
   <InputLabel
-  - FormLabelClasses={{ asterisk: 'bar' } }
-  + classes={{ asterisk: 'bar' } }
+  - FormLabelClasses={{ asterisk: 'bar' }}
+  + classes={{ asterisk: 'bar' }}
   >
     Foo
   </InputLabel>
diff --git a/docs/src/pages/styles/advanced/advanced.md b/docs/src/pages/styles/advanced/advanced.md
--- a/docs/src/pages/styles/advanced/advanced.md
+++ b/docs/src/pages/styles/advanced/advanced.md
@@ -580,7 +580,7 @@ If you are using Server-Side Rendering (SSR), you should pass the nonce in the `
 <style
   id="jss-server-side"
   nonce={nonce}
-  dangerouslySetInnerHTML={{ __html: sheets.toString() } }
+  dangerouslySetInnerHTML={{ __html: sheets.toString() }}
 />
 ```
 
diff --git a/docs/src/pages/system/basics/basics.md b/docs/src/pages/system/basics/basics.md
--- a/docs/src/pages/system/basics/basics.md
+++ b/docs/src/pages/system/basics/basics.md
@@ -211,8 +211,8 @@ const Box = styled.div`
 
 <Box
   p={2}
-  sm={{ p: 3 } }
-  md={{ p: 4 } }
+  sm={{ p: 3 }}
+  md={{ p: 4 }}
 />
 
 /**
diff --git a/docs/static/_redirects b/docs/static/_redirects
--- a/docs/static/_redirects
+++ b/docs/static/_redirects
@@ -41,6 +41,7 @@ https://material-ui.dev/* https://material-ui.com/:splat 301!
 /r/styles-instance-warning /getting-started/faq/#i-have-several-instances-of-styles-on-the-page 302
 /r/caveat-with-refs-guide /guides/composition/#caveat-with-refs 302
 /r/pseudo-classes-guide /customization/components/#pseudo-classes 302
+/r/input-component-ref-interface /components/text-fields/#integration-with-3rd-party-input-libraries 302
 
 # Legacy
 /v0.20.0 https://v0.material-ui.com/v0.20.0
diff --git a/packages/material-ui/src/InputBase/InputBase.js b/packages/material-ui/src/InputBase/InputBase.js
--- a/packages/material-ui/src/InputBase/InputBase.js
+++ b/packages/material-ui/src/InputBase/InputBase.js
@@ -277,8 +277,17 @@ const InputBase = React.forwardRef(function InputBase(props, ref) {
 
   const handleChange = (event, ...args) => {
     if (!isControlled) {
+      const element = event.target || inputRef.current;
+      if (element == null) {
+        throw new TypeError(
+          'Material-UI: Expected valid input target. ' +
+            'Did you use a custom `inputComponent` and forget to forward refs? ' +
+            'See https://material-ui.com/r/input-component-ref-interface for more info.',
+        );
+      }
+
       checkDirty({
-        value: (event.target || inputRef.current).value,
+        value: element.value,
       });
     }
 


Test patch:
diff --git a/docs/src/modules/utils/parseMarkdown.test.js b/docs/src/modules/utils/parseMarkdown.test.js
new file mode 100644
--- /dev/null
+++ b/docs/src/modules/utils/parseMarkdown.test.js
@@ -0,0 +1,26 @@
+import { expect } from 'chai';
+import { getContents } from './parseMarkdown';
+
+describe('parseMarkdown', () => {
+  describe('getContents', () => {
+    describe('Split markdown into an array, separating demos', () => {
+      it('returns a single entry without a demo', () => {
+        expect(getContents('# SomeGuide\nwhich has no demo')).to.deep.equal([
+          '# SomeGuide\nwhich has no demo',
+        ]);
+      });
+
+      it('uses a `{{"demo"` marker to split', () => {
+        expect(
+          getContents('# SomeGuide\n{{"demo": "GuideDemo.js" }}\n## NextHeading'),
+        ).to.deep.equal(['# SomeGuide\n', '"demo": "GuideDemo.js" ', '\n## NextHeading']);
+      });
+
+      it('ignores possible code', () => {
+        expect(getContents('# SomeGuide\n```jsx\n<Button props={{\nfoo: 1\n}}')).to.deep.equal([
+          '# SomeGuide\n```jsx\n<Button props={{\nfoo: 1\n}}',
+        ]);
+      });
+    });
+  });
+});
diff --git a/packages/material-ui/src/InputBase/InputBase.test.js b/packages/material-ui/src/InputBase/InputBase.test.js
--- a/packages/material-ui/src/InputBase/InputBase.test.js
+++ b/packages/material-ui/src/InputBase/InputBase.test.js
@@ -182,6 +182,103 @@ describe('<InputBase />', () => {
       expect(typeof injectedProps.onBlur).to.equal('function');
       expect(typeof injectedProps.onFocus).to.equal('function');
     });
+
+    describe('target mock implementations', () => {
+      it('can just mock the value', () => {
+        function MockedValue(props) {
+          const { onChange } = props;
+
+          function handleChange(event) {
+            onChange({ target: { value: event.target.value } });
+          }
+
+          return <input onChange={handleChange} />;
+        }
+        MockedValue.propTypes = { onChange: PropTypes.func.isRequired };
+
+        function FilledState(props) {
+          const { filled } = useFormControl();
+          return <span {...props}>filled: {String(filled)}</span>;
+        }
+
+        const { getByRole, getByTestId } = render(
+          <FormControl>
+            <FilledState data-testid="filled" />
+            <InputBase inputComponent={MockedValue} />
+          </FormControl>,
+        );
+        expect(getByTestId('filled')).to.have.text('filled: false');
+
+        fireEvent.change(getByRole('textbox'), { target: { value: 1 } });
+        expect(getByTestId('filled')).to.have.text('filled: true');
+      });
+
+      it('can expose the full target with `inputRef`', () => {
+        function FullTarget(props) {
+          const { inputRef, ...other } = props;
+
+          return <input ref={inputRef} {...other} />;
+        }
+        FullTarget.propTypes = {
+          inputRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+        };
+
+        function FilledState(props) {
+          const { filled } = useFormControl();
+          return <span {...props}>filled: {String(filled)}</span>;
+        }
+
+        const { getByRole, getByTestId } = render(
+          <FormControl>
+            <FilledState data-testid="filled" />
+            <InputBase inputComponent={FullTarget} />
+          </FormControl>,
+        );
+        expect(getByTestId('filled')).to.have.text('filled: false');
+
+        fireEvent.change(getByRole('textbox'), { target: { value: 1 } });
+        expect(getByTestId('filled')).to.have.text('filled: true');
+      });
+    });
+
+    describe('errors', () => {
+      it('throws on change if the target isnt mocked', () => {
+        /**
+         * This component simulates a custom input component that hides the inner
+         * input value for security reasons e.g. react-stripe-element.
+         *
+         * A ref is exposed to trigger a change event instead of using fireEvent.change
+         */
+        function BadInputComponent(props) {
+          const { onChange, triggerChangeRef } = props;
+
+          // simulates const handleChange = () => onChange({}) and passing that
+          // handler to the onChange prop of `input`
+          React.useImperativeHandle(triggerChangeRef, () => () => onChange({}));
+
+          return <input />;
+        }
+        BadInputComponent.propTypes = {
+          onChange: PropTypes.func.isRequired,
+          triggerChangeRef: PropTypes.object,
+        };
+
+        const triggerChangeRef = React.createRef();
+        render(<InputBase inputProps={{ triggerChangeRef }} inputComponent={BadInputComponent} />);
+
+        // mocking fireEvent.change(getByRole('textbox'), { target: { value: 1 } });
+        // using dispatchEvents prevents us from catching the error in the browser
+        // in test:karma neither try-catch nor consoleErrorMock.spy catches the error
+        let errorMessage = '';
+        try {
+          triggerChangeRef.current();
+        } catch (error) {
+          errorMessage = String(error);
+        }
+
+        expect(errorMessage).to.include('Material-UI: Expected valid input target');
+      });
+    });
   });
 
   describe('with FormControl', () => {