Repository: jsx-eslint/eslint-plugin-react (JavaScript)

Issue:
react/jsx-props-no-spreading doesn't support "sub-components".
**Dependencies:**
- `"eslint": "^6.7.2"`
- `"eslint-plugin-react": "^7.17.0"`

**.eslintrc.json**
```
{
  ...
  "react/jsx-props-no-spreading": [
    "error",
    {
      "exceptions": [
        "AsyncSelect",
        "Select",
        "components.Group",
        "components.ClearIndicator",
        "components.DropdownIndicator"
      ]
    }
  ]
  ...
}
```

**What happens?**

The following patterns are considered warnings regardless they are in the exception list:

```jsx
<components.Group {...groupProps} />
<components.DropdownIndicator {...props} />
<components.ClearIndicator {...props} />
```

The following patterns are **not** considered warnings:

```jsx
<AsyncSelect {...props} />
<Select {...getCustomProps({})} />
```



Discussion:
What I noticed is that sub-components don't have a `JSXIdentifier` object instead they have `JSXMemberExpression`. Being said that the `tagName` ends up being `undefined`. For a better reference:

**Select:**
```jsx
<Select {...getCustomProps({})} />
```

```
Node {
  type: 'JSXSpreadAttribute',
  start: 1918,
  end: 2314,
  loc:
   SourceLocation {
     start: Position { line: 64, column: 14 },
     end: Position { line: 74, column: 17 } },
  range: [ 1918, 2314 ],
  argument:
   Node {
     type: 'CallExpression',
     start: 1922,
     end: 2313,
     loc: SourceLocation { start: [Position], end: [Position] },
     range: [ 1922, 2313 ],
     callee:
      Node {
        type: 'Identifier',
        start: 1922,
        end: 1933,
        loc: [SourceLocation],
        range: [Array],
        name: 'getCustomProps',
        _babelType: 'Identifier',
        parent: [Circular] },
     arguments: [ [Node] ],
     _babelType: 'CallExpression',
     parent: [Circular] },
  _babelType: 'JSXSpreadAttribute',
  parent:
   Node {
     type: 'JSXOpeningElement',
     start: 1896,
     end: 2329,
     loc: SourceLocation { start: [Position], end: [Position] },
     range: [ 1896, 2329 ],
     name:
      Node {
        type: 'JSXIdentifier',
        start: 1897,
        end: 1903,
        loc: [SourceLocation],
        range: [Array],
        name: 'Select',
        _babelType: 'JSXIdentifier',
        parent: [Circular] },
     attributes: [ [Circular] ],
     selfClosing: true,
     _babelType: 'JSXOpeningElement',
     parent:
      Node {
        type: 'JSXElement',
        start: 1896,
        end: 2329,
        loc: [SourceLocation],
        range: [Array],
        openingElement: [Circular],
        closingElement: null,
        children: [],
        _babelType: 'JSXElement',
        parent: [Node] } } }
```

**components.Group**
```jsx
<components.Group {...groupProps} />
```

```
Node {
  type: 'JSXSpreadAttribute',
  start: 1065,
  end: 1080,
  loc:
   SourceLocation {
     start: Position { line: 32, column: 24 },
     end: Position { line: 32, column: 39 } },
  range: [ 1065, 1080 ],
  argument:
   Node {
     type: 'Identifier',
     start: 1069,
     end: 1079,
     loc:
      SourceLocation {
        start: [Position],
        end: [Position],
        identifierName: 'groupProps' },
     range: [ 1069, 1079 ],
     name: 'groupProps',
     _babelType: 'Identifier',
     parent: [Circular] },
  _babelType: 'JSXSpreadAttribute',
  parent:
   Node {
     type: 'JSXOpeningElement',
     start: 1047,
     end: 1083,
     loc: SourceLocation { start: [Position], end: [Position] },
     range: [ 1047, 1083 ],
     name:
      Node {
        type: 'JSXMemberExpression',
        start: 1048,
        end: 1064,
        loc: [SourceLocation],
        range: [Array],
        object: [Node],
        property: [Node],
        _babelType: 'JSXMemberExpression',
        parent: [Circular] },
     attributes: [ [Circular] ],
     selfClosing: true,
     _babelType: 'JSXOpeningElement',
     parent:
      Node {
        type: 'JSXElement',
        start: 1047,
        end: 1083,
        loc: [SourceLocation],
        range: [Array],
        openingElement: [Circular],
        closingElement: null,
        children: [],
        _babelType: 'JSXElement',
        parent: [Node] } } }
```

I'm not that familiar with eslint rules but I can get it done if anyone points me in the right direction
The rule implementation is here: https://github.com/yannickcr/eslint-plugin-react/blob/master/lib/rules/jsx-props-no-spreading.js

This line: https://github.com/yannickcr/eslint-plugin-react/blob/master/lib/rules/jsx-props-no-spreading.js#L86 probably needs to be adjusted to account for namespaced custom components? I'd start by adding test cases, and then play with the rule impl starting at that line.
Thanks for your prompt response @ljharb 

As I mentioned before, `tagName` is `undefined` when analyzing sub-components, and it is according to the constant definition

```js
const tagName = node.parent.name.name;
```
it expects `name` property at the `JSXIdentifier` but in sub-components the object structure contains a `JSXMemberExpression` instead of the Identifier, which doesn't have the expected prop to get the tagName.


So, I don't think it would make too much sense to start from [line 86](https://github.com/yannickcr/eslint-plugin-react/blob/master/lib/rules/jsx-props-no-spreading.js#L86)
```js
const isCustomTag = tagName && tagName[0] === tagName[0].toUpperCase();
```

 until we find a way to identify sub-components pattern structure or at least how to get their tagName
@ljharb 

This rule gets the tagName from the `name` property of the following structure:
```
Node {
  type: 'JSXIdentifier',
  start: 1897,
  end: 1903,
  loc: [SourceLocation],
  range: [Array],
  name: 'Select',
  _babelType: 'JSXIdentifier',
  parent: [Circular]
}
```

However, when analyzing sub-components the object at this exact level is represented by the following structure:
```
Node {
  type: 'JSXMemberExpression',
  start: 1048,
  end: 1064,
  loc: [SourceLocation],
  range: [Array],
  object: [Node],
  property: [Node],
  _babelType: 'JSXMemberExpression',
  parent: [Circular]
}
```
Sounds great, a PR would be most appreciated.
Awesome, I'll work on it
@ljharb I have added some tests in [here](https://github.com/yannickcr/eslint-plugin-react/compare/master...jonathanpalma:master), could you please review them prior to proceeding to design my solution?


For better traceability, I will be adding some references to external resources that might help to solve this issue.

According to [JSXOpeningElement](https://babeljs.io/docs/en/babel-types#jsxopeningelement), `name` property is required and should be of any of the following types: 
- [JSXIdentifier](https://babeljs.io/docs/en/babel-types#jsxidentifier) -> This one contains the tag name.
- [JSXMemberExpression](https://babeljs.io/docs/en/babel-types#jsxmemberexpression) -> This one contains a `property` property which is required and should be of type `JSXIdentifier`. 

What I could find out through some tests is that when analyzing components (html or custom) the parser returns a  `JSXIdentifier`, whereas analyzing sub-components (i.e. components.Group, Nav.Tab) the parser returns `JSXMemberExpression` which contains a `JSXIdentifier` Node with the following structure:

```
Prop Node {
  type: 'JSXIdentifier',
  start: 1059,
  end: 1064,
  loc: SourceLocation {
    start: Position { line: 32, column: 18 },
    end: Position { line: 32, column: 23 }
  },
  range: [ 1059, 1064 ],
  name: 'Group',
  _babelType: 'JSXIdentifier',
  parent: Node {
    type: 'JSXMemberExpression',
    start: 1048,
    end: 1064,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 1048, 1064 ],
    object: Node {
      type: 'JSXIdentifier',
      start: 1048,
      end: 1058,
      loc: [SourceLocation],
      range: [Array],
      name: 'components',
      _babelType: 'JSXIdentifier',
      parent: [Circular]
    },
    property: [Circular],
    _babelType: 'JSXMemberExpression',
    parent: Node {
      type: 'JSXOpeningElement',
      start: 1047,
      end: 1083,
      loc: [SourceLocation],
      range: [Array],
      name: [Circular],
      attributes: [Array],
      selfClosing: true,
      _babelType: 'JSXOpeningElement',
      parent: [Node]
    }
  }
}
```
Those tests look great to me!

Note that the rule needs to work with the default eslint parser, the babel parser in babel-eslint, and the typescript parser, and your links only deal with the babel parser.  It would be helpful to duplicate each of the test cases, and make sure each one runs on each of these parsers.



Patch:
diff --git a/.editorconfig b/.editorconfig
index 4a7ea3036a..319299684a 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -7,6 +7,7 @@ end_of_line = lf
 charset = utf-8
 trim_trailing_whitespace = true
 insert_final_newline = true
+quote_type = single
 
 [*.md]
 trim_trailing_whitespace = false
diff --git a/lib/rules/jsx-props-no-spreading.js b/lib/rules/jsx-props-no-spreading.js
index 2a311a0a6f..76e5b0b2be 100644
--- a/lib/rules/jsx-props-no-spreading.js
+++ b/lib/rules/jsx-props-no-spreading.js
@@ -79,11 +79,23 @@ module.exports = {
     const exceptions = configuration.exceptions || DEFAULTS.exceptions;
     const isException = (tag, allExceptions) => allExceptions.indexOf(tag) !== -1;
     const isProperty = property => property.type === 'Property';
+    const getTagNameFromMemberExpression = node => `${node.property.parent.object.name}.${node.property.name}`;
     return {
       JSXSpreadAttribute(node) {
-        const tagName = node.parent.name.name;
+        const jsxOpeningElement = node.parent.name;
+        const type = jsxOpeningElement.type;
+
+        let tagName;
+        if (type === 'JSXIdentifier') {
+          tagName = jsxOpeningElement.name;
+        } else if (type === 'JSXMemberExpression') {
+          tagName = getTagNameFromMemberExpression(jsxOpeningElement);
+        } else {
+          tagName = undefined;
+        }
+
         const isHTMLTag = tagName && tagName[0] !== tagName[0].toUpperCase();
-        const isCustomTag = tagName && tagName[0] === tagName[0].toUpperCase();
+        const isCustomTag = tagName && (tagName[0] === tagName[0].toUpperCase() || tagName.includes('.'));
         if (
           isHTMLTag &&
           ((ignoreHtmlTags && !isException(tagName, exceptions)) ||


Test patch:
diff --git a/tests/lib/rules/jsx-props-no-spreading.js b/tests/lib/rules/jsx-props-no-spreading.js
index fbb22504b3..016c00590f 100644
--- a/tests/lib/rules/jsx-props-no-spreading.js
+++ b/tests/lib/rules/jsx-props-no-spreading.js
@@ -89,6 +89,37 @@ ruleTester.run('jsx-props-no-spreading', rule, {
       </App>
     `,
     options: [{explicitSpread: 'ignore'}]
+  }, {
+    code: [
+      'const props = {};',
+      '<App>',
+      '   <components.Group {...props}/>',
+      '   <Nav.Item {...props}/>',
+      '</App>'
+    ].join('\n'),
+    options: [{exceptions: ['components.Group', 'Nav.Item']}]
+  }, {
+    code: [
+      'const props = {};',
+      '<App>',
+      '   <components.Group {...props}/>',
+      '   <Nav.Item {...props}/>',
+      '</App>'
+    ].join('\n'),
+    options: [{custom: 'ignore'}]
+  }, {
+    code: [
+      'const props = {};',
+      '<App>',
+      '   <components.Group {...props}/>',
+      '   <Nav.Item {...props}/>',
+      '</App>'
+    ].join('\n'),
+    options: [{
+      custom: 'enforce',
+      html: 'ignore',
+      exceptions: ['components.Group', 'Nav.Item']
+    }]
   }],
 
   invalid: [{
@@ -192,5 +223,15 @@ ruleTester.run('jsx-props-no-spreading', rule, {
     `,
     options: [{explicitSpread: 'ignore'}],
     errors: [expectedError]
+  }, {
+    code: [
+      'const props = {};',
+      '<App>',
+      '   <components.Group {...props}/>',
+      '   <Nav.Item {...props}/>',
+      '</App>'
+    ].join('\n'),
+    options: [{exceptions: ['components.DropdownIndicator', 'Nav.Item']}],
+    errors: [expectedError]
   }]
 });