Skip to content

Commit dfe190b

Browse files
authored
Merge pull request #1134 from MatthewHerbst/jsx-sort-props/new-rule-option--reservedFirst
[New] Add a `reservedFirst` option to the `jsx-sort-props` rule
2 parents 14dbf99 + 6e18e40 commit dfe190b

File tree

3 files changed

+242
-5
lines changed

3 files changed

+242
-5
lines changed

docs/rules/jsx-sort-props.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ The following patterns are considered okay and do not cause warnings:
2828
"shorthandFirst": <boolean>,
2929
"shorthandLast": <boolean>,
3030
"ignoreCase": <boolean>,
31-
"noSortAlphabetically": <boolean>
31+
"noSortAlphabetically": <boolean>,
32+
"reservedFirst": <boolean>|<array<string>>,
3233
}]
3334
...
3435
```
@@ -75,6 +76,26 @@ When `true`, alphabetical order is not enforced:
7576
<Hello tel={5555555} name="John" />
7677
```
7778

79+
### `reservedFirst`
80+
81+
This can be a boolean or an array option.
82+
83+
When `reservedFirst` is defined, React reserved props (`children`, `dangerouslySetInnerHTML` - **only for DOM components**, `key`, and `ref`) must be listed before all other props, but still respecting the alphabetical order:
84+
85+
```jsx
86+
<Hello key={0} ref="John" name="John">
87+
<div dangerouslySetInnerHTML={{__html: 'ESLint Plugin React!'}} ref="dangerDiv" />
88+
</Hello>
89+
```
90+
91+
If given as an array, the array's values will override the default list of reserved props. **Note**: the values in the array may only be a **subset** of React reserved props.
92+
93+
With `reservedFirst: [2, ["key"]]`, the following will not warn:
94+
95+
```jsx
96+
<Hello key={'uuid'} name="John" ref="ref" />
97+
```
98+
7899
## When not to use
79100

80101
This rule is a formatting preference and not following it won't negatively affect the quality of your code. If alphabetizing props isn't a part of your coding standards, then you can leave this rule off.

lib/rules/jsx-sort-props.js

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
'use strict';
66

7+
var elementType = require('jsx-ast-utils/elementType');
78
var propName = require('jsx-ast-utils/propName');
89

910
// ------------------------------------------------------------------------------
@@ -14,6 +15,72 @@ function isCallbackPropName(name) {
1415
return /^on[A-Z]/.test(name);
1516
}
1617

18+
var COMPAT_TAG_REGEX = /^[a-z]|\-/;
19+
function isDOMComponent(node) {
20+
var name = elementType(node);
21+
22+
// Get namespace if the type is JSXNamespacedName or JSXMemberExpression
23+
if (name.indexOf(':') > -1) {
24+
name = name.substring(0, name.indexOf(':'));
25+
} else if (name.indexOf('.') > -1) {
26+
name = name.substring(0, name.indexOf('.'));
27+
}
28+
29+
return COMPAT_TAG_REGEX.test(name);
30+
}
31+
32+
var RESERVED_PROPS_LIST = [
33+
'children',
34+
'dangerouslySetInnerHTML',
35+
'key',
36+
'ref'
37+
];
38+
39+
function isReservedPropName(name, list) {
40+
return list.indexOf(name) >= 0;
41+
}
42+
43+
/**
44+
* Checks if the `reservedFirst` option is valid
45+
* @param {Object} context The context of the rule
46+
* @param {Boolean|Array<String>} reservedFirst The `reservedFirst` option
47+
* @return {?Function} If an error is detected, a function to generate the error message, otherwise, `undefined`
48+
*/
49+
// eslint-disable-next-line consistent-return
50+
function validateReservedFirstConfig(context, reservedFirst) {
51+
if (reservedFirst) {
52+
if (Array.isArray(reservedFirst)) {
53+
// Only allow a subset of reserved words in customized lists
54+
// eslint-disable-next-line consistent-return
55+
var nonReservedWords = reservedFirst.filter(function(word) {
56+
if (!isReservedPropName(word, RESERVED_PROPS_LIST)) {
57+
return true;
58+
}
59+
});
60+
61+
if (reservedFirst.length === 0) {
62+
return function(decl) {
63+
context.report({
64+
node: decl,
65+
message: 'A customized reserved first list must not be empty'
66+
});
67+
};
68+
} else if (nonReservedWords.length > 0) {
69+
return function(decl) {
70+
context.report({
71+
node: decl,
72+
message: 'A customized reserved first list must only contain a subset of React reserved props.' +
73+
' Remove: {{ nonReservedWords }}',
74+
data: {
75+
nonReservedWords: nonReservedWords.toString()
76+
}
77+
});
78+
};
79+
}
80+
}
81+
}
82+
}
83+
1784
module.exports = {
1885
meta: {
1986
docs: {
@@ -44,6 +111,9 @@ module.exports = {
44111
// Whether alphabetical sorting should be enforced
45112
noSortAlphabetically: {
46113
type: 'boolean'
114+
},
115+
reservedFirst: {
116+
type: ['array', 'boolean']
47117
}
48118
},
49119
additionalProperties: false
@@ -58,9 +128,19 @@ module.exports = {
58128
var shorthandFirst = configuration.shorthandFirst || false;
59129
var shorthandLast = configuration.shorthandLast || false;
60130
var noSortAlphabetically = configuration.noSortAlphabetically || false;
131+
var reservedFirst = configuration.reservedFirst || false;
132+
var reservedFirstError = validateReservedFirstConfig(context, reservedFirst);
133+
var reservedList = Array.isArray(reservedFirst) ? reservedFirst : RESERVED_PROPS_LIST;
61134

62135
return {
63136
JSXOpeningElement: function(node) {
137+
// `dangerouslySetInnerHTML` is only "reserved" on DOM components
138+
if (reservedFirst && !isDOMComponent(node)) {
139+
reservedList = reservedList.filter(function(prop) {
140+
return prop !== 'dangerouslySetInnerHTML';
141+
});
142+
}
143+
64144
node.attributes.reduce(function(memo, decl, idx, attrs) {
65145
if (decl.type === 'JSXSpreadAttribute') {
66146
return attrs[idx + 1];
@@ -70,15 +150,45 @@ module.exports = {
70150
var currentPropName = propName(decl);
71151
var previousValue = memo.value;
72152
var currentValue = decl.value;
73-
var previousIsCallback = isCallbackPropName(previousPropName);
74-
var currentIsCallback = isCallbackPropName(currentPropName);
75153

76154
if (ignoreCase) {
77155
previousPropName = previousPropName.toLowerCase();
78156
currentPropName = currentPropName.toLowerCase();
79157
}
80158

159+
if (reservedFirst) {
160+
if (reservedFirstError) {
161+
reservedFirstError(decl);
162+
return memo;
163+
}
164+
165+
var previousIsReserved = isReservedPropName(previousPropName, reservedList);
166+
var currentIsReserved = isReservedPropName(currentPropName, reservedList);
167+
168+
if (previousIsReserved && currentIsReserved) {
169+
if (!noSortAlphabetically && currentPropName < previousPropName) {
170+
context.report({
171+
node: decl,
172+
message: 'Props should be sorted alphabetically'
173+
});
174+
return memo;
175+
}
176+
return decl;
177+
}
178+
if (!previousIsReserved && currentIsReserved) {
179+
context.report({
180+
node: decl,
181+
message: 'Reserved props must be listed before all other props'
182+
});
183+
return memo;
184+
}
185+
return decl;
186+
}
187+
81188
if (callbacksLast) {
189+
var previousIsCallback = isCallbackPropName(previousPropName);
190+
var currentIsCallback = isCallbackPropName(currentPropName);
191+
82192
if (!previousIsCallback && currentIsCallback) {
83193
// Entering the callback prop section
84194
return decl;

tests/lib/rules/jsx-sort-props.js

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ var expectedShorthandLastError = {
4141
message: 'Shorthand props must be listed after all other props',
4242
type: 'JSXAttribute'
4343
};
44+
var expectedReservedFirstError = {
45+
message: 'Reserved props must be listed before all other props',
46+
type: 'JSXAttribute'
47+
};
48+
var expectedEmptyReservedFirstError = {
49+
message: 'A customized reserved first list must not be empty'
50+
};
51+
var expectedInvalidReservedFirstError = {
52+
message: 'A customized reserved first list must only contain a subset of React reserved props. Remove: notReserved'
53+
};
4454
var callbacksLastArgs = [{
4555
callbacksLast: true
4656
}];
@@ -63,6 +73,22 @@ var noSortAlphabeticallyArgs = [{
6373
var sortAlphabeticallyArgs = [{
6474
noSortAlphabetically: false
6575
}];
76+
var reservedFirstAsBooleanArgs = [{
77+
reservedFirst: true
78+
}];
79+
var reservedFirstAsArrayArgs = [{
80+
reservedFirst: ['children', 'dangerouslySetInnerHTML', 'key']
81+
}];
82+
var reservedFirstWithNoSortAlphabeticallyArgs = [{
83+
noSortAlphabetically: true,
84+
reservedFirst: true
85+
}];
86+
var reservedFirstAsEmptyArrayArgs = [{
87+
reservedFirst: []
88+
}];
89+
var reservedFirstAsInvalidArrayArgs = [{
90+
reservedFirst: ['notReserved']
91+
}];
6692

6793
ruleTester.run('jsx-sort-props', rule, {
6894
valid: [
@@ -93,7 +119,38 @@ ruleTester.run('jsx-sort-props', rule, {
93119
},
94120
// noSortAlphabetically
95121
{code: '<App a b />;', options: noSortAlphabeticallyArgs, parserOptions: parserOptions},
96-
{code: '<App b a />;', options: noSortAlphabeticallyArgs, parserOptions: parserOptions}
122+
{code: '<App b a />;', options: noSortAlphabeticallyArgs, parserOptions: parserOptions},
123+
// reservedFirst
124+
{
125+
code: '<App children={<App />} key={0} ref="r" a b c />',
126+
options: reservedFirstAsBooleanArgs,
127+
parserOptions: parserOptions
128+
},
129+
{
130+
code: '<App children={<App />} key={0} ref="r" a b c dangerouslySetInnerHTML={{__html: "EPR"}} />',
131+
options: reservedFirstAsBooleanArgs,
132+
parserOptions: parserOptions
133+
},
134+
{
135+
code: '<App children={<App />} key={0} a ref="r" />',
136+
options: reservedFirstAsArrayArgs,
137+
parserOptions: parserOptions
138+
},
139+
{
140+
code: '<App children={<App />} key={0} a dangerouslySetInnerHTML={{__html: "EPR"}} ref="r" />',
141+
options: reservedFirstAsArrayArgs,
142+
parserOptions: parserOptions
143+
},
144+
{
145+
code: '<App ref="r" key={0} children={<App />} b a c />',
146+
options: reservedFirstWithNoSortAlphabeticallyArgs,
147+
parserOptions: parserOptions
148+
},
149+
{
150+
code: '<div ref="r" dangerouslySetInnerHTML={{__html: "EPR"}} key={0} children={<App />} b a c />',
151+
options: reservedFirstWithNoSortAlphabeticallyArgs,
152+
parserOptions: parserOptions
153+
}
97154
],
98155
invalid: [
99156
{code: '<App b a />;', errors: [expectedError], parserOptions: parserOptions},
@@ -132,6 +189,55 @@ ruleTester.run('jsx-sort-props', rule, {
132189
options: shorthandLastArgs,
133190
parserOptions: parserOptions
134191
},
135-
{code: '<App b a />;', errors: [expectedError], options: sortAlphabeticallyArgs, parserOptions: parserOptions}
192+
{code: '<App b a />;', errors: [expectedError], options: sortAlphabeticallyArgs, parserOptions: parserOptions},
193+
// reservedFirst
194+
{
195+
code: '<App a key={1} />',
196+
options: reservedFirstAsBooleanArgs,
197+
errors: [expectedReservedFirstError],
198+
parserOptions: parserOptions
199+
},
200+
{
201+
code: '<div a dangerouslySetInnerHTML={{__html: "EPR"}} />',
202+
options: reservedFirstAsBooleanArgs,
203+
errors: [expectedReservedFirstError],
204+
parserOptions: parserOptions
205+
},
206+
{
207+
code: '<App ref="r" key={2} b />',
208+
options: reservedFirstAsBooleanArgs,
209+
errors: [expectedError],
210+
parserOptions: parserOptions
211+
},
212+
{
213+
code: '<App dangerouslySetInnerHTML={{__html: "EPR"}} key={2} b />',
214+
options: reservedFirstAsBooleanArgs,
215+
errors: [expectedReservedFirstError],
216+
parserOptions: parserOptions
217+
},
218+
{
219+
code: '<App key={3} children={<App />} />',
220+
options: reservedFirstAsArrayArgs,
221+
errors: [expectedError],
222+
parserOptions: parserOptions
223+
},
224+
{
225+
code: '<App z ref="r" />',
226+
options: reservedFirstWithNoSortAlphabeticallyArgs,
227+
errors: [expectedReservedFirstError],
228+
parserOptions: parserOptions
229+
},
230+
{
231+
code: '<App key={4} />',
232+
options: reservedFirstAsEmptyArrayArgs,
233+
errors: [expectedEmptyReservedFirstError],
234+
parserOptions: parserOptions
235+
},
236+
{
237+
code: '<App key={5} />',
238+
options: reservedFirstAsInvalidArrayArgs,
239+
errors: [expectedInvalidReservedFirstError],
240+
parserOptions: parserOptions
241+
}
136242
]
137243
});

0 commit comments

Comments
 (0)