diff --git a/README.md b/README.md
index 5b787b1afc..161c75663b 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,7 @@ Enable the rules that you would like to use.
* [react/jsx-no-undef](docs/rules/jsx-no-undef.md): Disallow undeclared variables in JSX
* [react/jsx-one-expression-per-line](docs/rules/jsx-one-expression-per-line.md): Limit to one expression per line in JSX
* [react/jsx-curly-brace-presence](docs/rules/jsx-curly-brace-presence.md): Enforce curly braces or disallow unnecessary curly braces in JSX
+* [react/jsx-fragments](docs/rules/jsx-fragments.md): Enforce shorthand or standard form for React fragments
* [react/jsx-pascal-case](docs/rules/jsx-pascal-case.md): Enforce PascalCase for user-defined JSX components
* [react/jsx-props-no-multi-spaces](docs/rules/jsx-props-no-multi-spaces.md): Disallow multiple spaces between inline JSX props (fixable)
* [react/jsx-sort-default-props](docs/rules/jsx-sort-default-props.md): Enforce default props alphabetical sorting
diff --git a/docs/rules/jsx-fragments.md b/docs/rules/jsx-fragments.md
new file mode 100644
index 0000000000..1620652b11
--- /dev/null
+++ b/docs/rules/jsx-fragments.md
@@ -0,0 +1,57 @@
+# Enforce shorthand or standard form for React fragments (react/jsx-fragments)
+
+In JSX, a React fragment is created either with `...`, or, using the shorthand syntax, `<>...>`. This rule allows you to enforce one way or the other.
+
+Support for fragments was added in React v16.2, so the rule will warn on either of these forms if an older React version is specified in [shared settings][shared_settings].
+
+## Rule Options
+
+```js
+...
+"react/jsx-fragments": [, ]
+...
+```
+
+### `syntax` mode
+
+This is the default mode. It will enforce the shorthand syntax for React fragments, with one exception. [Keys or attributes are not supported by the shorthand syntax][short_syntax], so the rule will not warn on standard-form fragments that use those.
+
+The following pattern is considered a warning:
+
+```jsx
+
+```
+
+The following patterns are **not** considered warnings:
+
+```jsx
+<>>
+```
+
+```jsx
+
+```
+
+### `element` mode
+
+This mode enforces the standard form for React fragments.
+
+The following pattern is considered a warning:
+
+```jsx
+<>>
+```
+
+The following patterns are **not** considered warnings:
+
+```jsx
+
+```
+
+```jsx
+
+```
+
+[fragments]: https://reactjs.org/docs/fragments.html
+[shared_settings]: /README.md#configuration
+[short_syntax]: https://reactjs.org/docs/fragments.html#short-syntax
diff --git a/index.js b/index.js
index 45a96d7da3..25faa607a6 100644
--- a/index.js
+++ b/index.js
@@ -36,6 +36,7 @@ const allRules = {
'jsx-no-undef': require('./lib/rules/jsx-no-undef'),
'jsx-curly-brace-presence': require('./lib/rules/jsx-curly-brace-presence'),
'jsx-pascal-case': require('./lib/rules/jsx-pascal-case'),
+ 'jsx-fragments': require('./lib/rules/jsx-fragments'),
'jsx-props-no-multi-spaces': require('./lib/rules/jsx-props-no-multi-spaces'),
'jsx-sort-default-props': require('./lib/rules/jsx-sort-default-props'),
'jsx-sort-props': require('./lib/rules/jsx-sort-props'),
diff --git a/lib/rules/jsx-fragments.js b/lib/rules/jsx-fragments.js
new file mode 100644
index 0000000000..da9d97322e
--- /dev/null
+++ b/lib/rules/jsx-fragments.js
@@ -0,0 +1,179 @@
+/**
+ * @fileoverview Enforce shorthand or standard form for React fragments.
+ * @author Alex Zherdev
+ */
+'use strict';
+
+const elementType = require('jsx-ast-utils/elementType');
+const pragmaUtil = require('../util/pragma');
+const variableUtil = require('../util/variable');
+const versionUtil = require('../util/version');
+const docsUrl = require('../util/docsUrl');
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+function replaceNode(source, node, text) {
+ return `${source.slice(0, node.range[0])}${text}${source.slice(node.range[1])}`;
+}
+
+module.exports = {
+ meta: {
+ docs: {
+ description: 'Enforce shorthand or standard form for React fragments',
+ category: 'Stylistic Issues',
+ recommended: false,
+ url: docsUrl('jsx-fragments')
+ },
+ fixable: 'code',
+
+ schema: [{
+ enum: ['syntax', 'element']
+ }]
+ },
+
+ create: function(context) {
+ const configuration = context.options[0] || 'syntax';
+ const sourceCode = context.getSourceCode();
+ const reactPragma = pragmaUtil.getFromContext(context);
+ const fragmentPragma = pragmaUtil.getFragmentFromContext(context);
+ const openFragShort = '<>';
+ const closeFragShort = '>';
+ const openFragLong = `<${reactPragma}.${fragmentPragma}>`;
+ const closeFragLong = `${reactPragma}.${fragmentPragma}>`;
+
+ function reportOnReactVersion(node) {
+ if (!versionUtil.testReactVersion(context, '16.2.0')) {
+ context.report({
+ node,
+ message: 'Fragments are only supported starting from React v16.2'
+ });
+ return true;
+ }
+
+ return false;
+ }
+
+ function getFixerToLong(jsxFragment) {
+ return function(fixer) {
+ let source = sourceCode.getText();
+ source = replaceNode(source, jsxFragment.closingFragment, closeFragLong);
+ source = replaceNode(source, jsxFragment.openingFragment, openFragLong);
+ const lengthDiff = openFragLong.length - sourceCode.getText(jsxFragment.openingFragment).length
+ + closeFragLong.length - sourceCode.getText(jsxFragment.closingFragment).length;
+ const range = jsxFragment.range;
+ return fixer.replaceTextRange(range, source.slice(range[0], range[1] + lengthDiff));
+ };
+ }
+
+ function getFixerToShort(jsxElement) {
+ return function(fixer) {
+ let source = sourceCode.getText();
+ source = replaceNode(source, jsxElement.closingElement, closeFragShort);
+ source = replaceNode(source, jsxElement.openingElement, openFragShort);
+ const lengthDiff = sourceCode.getText(jsxElement.openingElement).length - openFragShort.length
+ + sourceCode.getText(jsxElement.closingElement).length - closeFragShort.length;
+ const range = jsxElement.range;
+ return fixer.replaceTextRange(range, source.slice(range[0], range[1] - lengthDiff));
+ };
+ }
+
+ function refersToReactFragment(name) {
+ const variableInit = variableUtil.findVariableByName(context, name);
+ if (!variableInit) {
+ return false;
+ }
+
+ // const { Fragment } = React;
+ if (variableInit.type === 'Identifier' && variableInit.name === reactPragma) {
+ return true;
+ }
+
+ // const Fragment = React.Fragment;
+ if (
+ variableInit.type === 'MemberExpression'
+ && variableInit.object.type === 'Identifier'
+ && variableInit.object.name === reactPragma
+ && variableInit.property.type === 'Identifier'
+ && variableInit.property.name === fragmentPragma
+ ) {
+ return true;
+ }
+
+ // const { Fragment } = require('react');
+ if (
+ variableInit.callee
+ && variableInit.callee.name === 'require'
+ && variableInit.arguments
+ && variableInit.arguments[0]
+ && variableInit.arguments[0].value === 'react'
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ const jsxElements = [];
+ const fragmentNames = new Set([`${reactPragma}.${fragmentPragma}`]);
+
+ // --------------------------------------------------------------------------
+ // Public
+ // --------------------------------------------------------------------------
+
+ return {
+ JSXElement(node) {
+ jsxElements.push(node);
+ },
+
+ JSXFragment(node) {
+ if (reportOnReactVersion(node)) {
+ return;
+ }
+
+ if (configuration === 'element') {
+ context.report({
+ node,
+ message: `Prefer ${reactPragma}.${fragmentPragma} over fragment shorthand`,
+ fix: getFixerToLong(node)
+ });
+ }
+ },
+
+ ImportDeclaration(node) {
+ if (node.source && node.source.value === 'react') {
+ node.specifiers.forEach(spec => {
+ if (spec.imported && spec.imported.name === fragmentPragma) {
+ if (spec.local) {
+ fragmentNames.add(spec.local.name);
+ }
+ }
+ });
+ }
+ },
+
+ 'Program:exit'() {
+ jsxElements.forEach(node => {
+ const openingEl = node.openingElement;
+ const elName = elementType(openingEl);
+
+ if (fragmentNames.has(elName) || refersToReactFragment(elName)) {
+ if (reportOnReactVersion(node)) {
+ return;
+ }
+
+ const attrs = openingEl.attributes;
+ if (configuration === 'syntax' && !(attrs && attrs.length > 0)) {
+ context.report({
+ node,
+ message: `Prefer fragment shorthand over ${reactPragma}.${fragmentPragma}`,
+ fix: getFixerToShort(node)
+ });
+ }
+ }
+ });
+ }
+ };
+ }
+};
diff --git a/lib/util/pragma.js b/lib/util/pragma.js
index 5162005938..27e3ed7fa7 100644
--- a/lib/util/pragma.js
+++ b/lib/util/pragma.js
@@ -21,6 +21,18 @@ function getCreateClassFromContext(context) {
return pragma;
}
+function getFragmentFromContext(context) {
+ let pragma = 'Fragment';
+ // .eslintrc shared settings (http://eslint.org/docs/user-guide/configuring#adding-shared-settings)
+ if (context.settings.react && context.settings.react.fragment) {
+ pragma = context.settings.react.fragment;
+ }
+ if (!JS_IDENTIFIER_REGEX.test(pragma)) {
+ throw new Error(`Fragment pragma ${pragma} is not a valid identifier`);
+ }
+ return pragma;
+}
+
function getFromContext(context) {
let pragma = 'React';
@@ -43,5 +55,6 @@ function getFromContext(context) {
module.exports = {
getCreateClassFromContext: getCreateClassFromContext,
+ getFragmentFromContext: getFragmentFromContext,
getFromContext: getFromContext
};
diff --git a/tests/lib/rules/jsx-fragments.js b/tests/lib/rules/jsx-fragments.js
new file mode 100644
index 0000000000..c230085040
--- /dev/null
+++ b/tests/lib/rules/jsx-fragments.js
@@ -0,0 +1,187 @@
+/**
+ * @fileoverview Tests for jsx-fragments
+ * @author Alex Zherdev
+ */
+'use strict';
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/jsx-fragments');
+const RuleTester = require('eslint').RuleTester;
+
+const parserOptions = {
+ ecmaVersion: 2018,
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true
+ }
+};
+
+const settings = {
+ react: {
+ version: '16.2',
+ pragma: 'Act',
+ fragment: 'Frag'
+ }
+};
+
+const settingsOld = {
+ react: {
+ version: '16.1',
+ pragma: 'Act',
+ fragment: 'Frag'
+ }
+};
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({parserOptions});
+ruleTester.run('jsx-fragments', rule, {
+ valid: [{
+ code: '<>>',
+ parser: 'babel-eslint',
+ settings
+ }, {
+ code: '',
+ options: ['element'],
+ settings
+ }, {
+ code: `
+ import Act, { Frag as F } from 'react';
+ ;
+ `,
+ options: ['element'],
+ settings
+ }, {
+ code: `
+ const F = Act.Frag;
+ ;
+ `,
+ options: ['element'],
+ settings
+ }, {
+ code: `
+ const { Frag } = Act;
+ ;
+ `,
+ options: ['element'],
+ settings
+ }, {
+ code: `
+ const { Frag } = require('react');
+ ;
+ `,
+ options: ['element'],
+ settings
+ }, {
+ code: '',
+ options: ['syntax'],
+ settings
+ }],
+
+ invalid: [{
+ code: '<>>',
+ parser: 'babel-eslint',
+ settings: settingsOld,
+ errors: [{
+ message: 'Fragments are only supported starting from React v16.2'
+ }]
+ }, {
+ code: '',
+ settings: settingsOld,
+ errors: [{
+ message: 'Fragments are only supported starting from React v16.2'
+ }]
+ }, {
+ code: '<>>',
+ parser: 'babel-eslint',
+ options: ['element'],
+ settings,
+ errors: [{
+ message: 'Prefer Act.Frag over fragment shorthand'
+ }],
+ output: ''
+ }, {
+ code: '',
+ options: ['syntax'],
+ settings,
+ errors: [{
+ message: 'Prefer fragment shorthand over Act.Frag'
+ }],
+ output: '<>>'
+ }, {
+ code: `
+ import Act, { Frag as F } from 'react';
+ ;
+ `,
+ options: ['syntax'],
+ settings,
+ errors: [{
+ message: 'Prefer fragment shorthand over Act.Frag'
+ }],
+ output: `
+ import Act, { Frag as F } from 'react';
+ <>>;
+ `
+ }, {
+ code: `
+ import Act, { Frag } from 'react';
+ ;
+ `,
+ options: ['syntax'],
+ settings,
+ errors: [{
+ message: 'Prefer fragment shorthand over Act.Frag'
+ }],
+ output: `
+ import Act, { Frag } from 'react';
+ <>>;
+ `
+ }, {
+ code: `
+ const F = Act.Frag;
+ ;
+ `,
+ options: ['syntax'],
+ settings,
+ errors: [{
+ message: 'Prefer fragment shorthand over Act.Frag'
+ }],
+ output: `
+ const F = Act.Frag;
+ <>>;
+ `
+ }, {
+ code: `
+ const { Frag } = Act;
+ ;
+ `,
+ options: ['syntax'],
+ settings,
+ errors: [{
+ message: 'Prefer fragment shorthand over Act.Frag'
+ }],
+ output: `
+ const { Frag } = Act;
+ <>>;
+ `
+ }, {
+ code: `
+ const { Frag } = require('react');
+ ;
+ `,
+ options: ['syntax'],
+ settings,
+ errors: [{
+ message: 'Prefer fragment shorthand over Act.Frag'
+ }],
+ output: `
+ const { Frag } = require('react');
+ <>>;
+ `
+ }]
+});