Skip to content

Commit

Permalink
Add support for stateless function components (fixes #237)
Browse files Browse the repository at this point in the history
  • Loading branch information
yannickcr committed Oct 18, 2015
1 parent f4cbdfc commit de6e7b5
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 67 deletions.
4 changes: 4 additions & 0 deletions lib/rules/display-name.js
Expand Up @@ -156,6 +156,10 @@ module.exports = function(context) {
markDisplayNameAsDeclared(node);
},

ReturnStatement: function(node) {
componentList.set(context, node);
},

'Program:exit': function() {
var list = componentList.getList();
// Report missing display name for all components
Expand Down
18 changes: 12 additions & 6 deletions lib/rules/prop-types.js
Expand Up @@ -31,10 +31,9 @@ module.exports = function(context) {
* @returns {Boolean} True if we are using a prop, false if not.
*/
function isPropTypesUsage(node) {
return Boolean(
node.object.type === 'ThisExpression' &&
node.property.name === 'props'
);
var isClassUsage = node.object.type === 'ThisExpression' && node.property.name === 'props';
var isStatelessFunctionUsage = node.object.name === 'props';
return isClassUsage || isStatelessFunctionUsage;
}

/**
Expand Down Expand Up @@ -314,6 +313,9 @@ module.exports = function(context) {
* @return {string} the name of the property or undefined if not found
*/
function getPropertyName(node) {
if (componentUtil.getNode(context, node)) {
node = node.parent;
}
var property = node.property;
if (property) {
switch (property.type) {
Expand Down Expand Up @@ -351,7 +353,7 @@ module.exports = function(context) {
var properties;
switch (node.type) {
case 'MemberExpression':
name = getPropertyName(node.parent);
name = getPropertyName(node);
if (name) {
allNames = parentNames.concat(name);
if (node.parent.type === 'MemberExpression') {
Expand Down Expand Up @@ -397,7 +399,7 @@ module.exports = function(context) {
usedPropTypes.push({
name: name,
allNames: allNames,
node: node.parent.property
node: node.object.name !== 'props' ? node.parent.property : node.property
});
break;
case 'destructuring':
Expand Down Expand Up @@ -589,6 +591,10 @@ module.exports = function(context) {
});
},

ReturnStatement: function(node) {
componentList.set(context, node);
},

'Program:exit': function() {
var list = componentList.getList();
// Report undeclared proptypes for all classes
Expand Down
107 changes: 53 additions & 54 deletions lib/util/component.js
Expand Up @@ -23,7 +23,7 @@ function isComponentDefinition(context, node) {
break;
case 'ClassDeclaration':
var superClass = node.superClass && context.getSource(node.superClass);
if (superClass === 'Component' && superClass === 'React.Component') {
if (superClass === 'Component' || superClass === 'React.Component') {
return true;
}
break;
Expand All @@ -34,44 +34,51 @@ function isComponentDefinition(context, node) {
}

/**
* Detect if the node is rendering some JSX
* Check if we are in a stateless function component
* @param {Object} context The current rule context.
* @param {ASTNode} node The AST node being checked.
* @returns {Boolean} True the node is rendering some JSX, false if not.
* @returns {Boolean} True if we are in a stateless function component, false if not.
*/
function isRenderingJSX(context, node) {
var tokens = context.getTokens(node);
for (var i = 0, j = tokens.length; i < j; i++) {
var hasJSX = /^JSX/.test(tokens[i].type);
var hasReact =
tokens[i].type === 'Identifier' && tokens[i].value === 'React' &&
tokens[i + 2] && tokens[i + 2].type === 'Identifier' && tokens[i + 2].value === 'createElement';
if (!hasJSX && !hasReact) {
continue;
function isStatelessFunctionComponent(context, node) {
if (node.type !== 'ReturnStatement') {
return false;
}

var scope = context.getScope();
while (scope) {
if (scope.type === 'class') {
return false;
}
return true;
scope = scope.upper;
}
return false;

var returnsJSX =
node.argument &&
node.argument.type === 'JSXElement'
;
var returnsReactCreateElement =
node.argument &&
node.argument.callee &&
node.argument.callee.property &&
node.argument.callee.property.name === 'createElement'
;

return Boolean(returnsJSX || returnsReactCreateElement);
}

/**
* Check if a class has a valid render method
* @param {Object} context The current rule context.
* @param {ASTNode} node The AST node being checked.
* @returns {Boolean} True the class has a valid render method, false if not.
* Get the identifiers of a React component ASTNode
* @param {ASTNode} node The React component ASTNode being checked.
* @returns {Object} The component identifiers.
*/
function isClassWithRender(context, node) {
if (node.type !== 'ClassDeclaration') {
return false;
}
for (var i = 0, j = node.body.body.length; i < j; i++) {
var declaration = node.body.body[i];
if (declaration.type !== 'MethodDefinition' || declaration.key.name !== 'render') {
continue;
}
return isRenderingJSX(context, declaration);
}
return false;
function getIdentifiers(node) {
var name = node.id && node.id.name || DEFAULT_COMPONENT_NAME;
var id = name + ':' + node.loc.start.line + ':' + node.loc.start.column;

return {
id: id,
name: name
};
}

/**
Expand All @@ -80,40 +87,31 @@ function isClassWithRender(context, node) {
* @param {ASTNode} node The AST node being checked.
* @returns {ASTNode} The ASTNode of the React component.
*/
function getNode(context, node) {
var componentNode = null;
function getNode(context, node, list) {
var ancestors = context.getAncestors().reverse();

ancestors.unshift(node);

for (var i = 0, j = ancestors.length; i < j; i++) {
if (isComponentDefinition(context, ancestors[i])) {
componentNode = ancestors[i];
break;
return ancestors[i];
}
if (isClassWithRender(context, ancestors[i])) {
componentNode = ancestors[i];
break;
// Node is already in the component list
var identifiers = getIdentifiers(ancestors[i]);
if (list && list[identifiers.id]) {
return ancestors[i];
}

}

return componentNode;
}

/**
* Get the identifiers of a React component ASTNode
* @param {ASTNode} node The React component ASTNode being checked.
* @returns {Object} The component identifiers.
*/
function getIdentifiers(node) {
var name = node.id && node.id.name || DEFAULT_COMPONENT_NAME;
var id = name + ':' + node.loc.start.line + ':' + node.loc.start.column;
if (isStatelessFunctionComponent(context, node)) {
var scope = context.getScope();
while (scope.upper && scope.type !== 'function') {
scope = scope.upper;
}
return scope.block;
}

return {
id: id,
name: name
};
return null;
}

/**
Expand Down Expand Up @@ -171,10 +169,11 @@ List.prototype.getList = function() {
* @returns {Object} The added component.
*/
List.prototype.set = function(context, node, customProperties) {
var componentNode = getNode(context, node);
var componentNode = getNode(context, node, this._list);
if (!componentNode) {
return null;
}

var identifiers = getIdentifiers(componentNode);

var component = util._extend({
Expand Down
38 changes: 31 additions & 7 deletions tests/lib/rules/display-name.js
Expand Up @@ -169,6 +169,15 @@ ruleTester.run('display-name', rule, {
experimentalObjectRestSpread: true,
jsx: true
}
}, {
code: [
'export default class {',
' render() {',
' return <div>Hello {this.props.name}</div>;',
' }',
'}'
].join('\n'),
parser: 'babel-eslint'
}],

invalid: [{
Expand Down Expand Up @@ -237,16 +246,31 @@ ruleTester.run('display-name', rule, {
}]
}, {
code: [
'export default class {',
' render() {',
' return <div>Hello {this.props.name}</div>;',
' }',
'var Hello = function() {',
' return <div>Hello {this.props.name}</div>;',
'}'
].join('\n'),
parser: 'babel-eslint',
errors: [{
message: 'Component definition is missing display name'
}]
}, {
code: [
'function Hello() {',
' return <div>Hello {this.props.name}</div>;',
'}'
].join('\n'),
parser: 'babel-eslint',
errors: [{
message: 'Hello component definition is missing display name'
}]
}, {
code: [
'var Hello = () => {',
' return <div>Hello {this.props.name}</div>;',
'}'
].join('\n'),
parser: 'babel-eslint',
options: [{
acceptTranspilerName: true
}],
errors: [{
message: 'Component definition is missing display name'
}]
Expand Down
43 changes: 43 additions & 0 deletions tests/lib/rules/prop-types.js
Expand Up @@ -1167,6 +1167,49 @@ ruleTester.run('prop-types', rule, {
errors: [
{message: '\'firstname\' is missing in props validation for Hello'}
]
}, {
code: [
'var Hello = function(props) {',
' return <div>Hello {props.name}</div>;',
'}'
].join('\n'),
parser: 'babel-eslint',
errors: [{
message: '\'name\' is missing in props validation'
}]
}, {
code: [
'function Hello(props) {',
' return <div>Hello {props.name}</div>;',
'}'
].join('\n'),
parser: 'babel-eslint',
errors: [{
message: '\'name\' is missing in props validation for Hello'
}]
}, {
code: [
'var Hello = (props) => {',
' return <div>Hello {props.name}</div>;',
'}'
].join('\n'),
parser: 'babel-eslint',
errors: [{
message: '\'name\' is missing in props validation'
}]
}, {
code: [
'class Hello extends React.Component {',
' render() {',
' var props = {firstname: \'John\'};',
' return <div>Hello {props.firstname} {this.props.lastname}</div>;',
' }',
'}'
].join('\n'),
parser: 'babel-eslint',
errors: [
{message: '\'lastname\' is missing in props validation for Hello'}
]
}
]
});

0 comments on commit de6e7b5

Please sign in to comment.