Skip to content

Commit 8f0ee89

Browse files
Clement398fiskersindresorhus
authoredFeb 23, 2024··
Add no-single-promise-in-promise-methods rule (#2258)
Co-authored-by: fisker <lionkay@gmail.com> Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
1 parent 1792d33 commit 8f0ee89

9 files changed

+1473
-0
lines changed
 

‎configs/recommended.js

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ module.exports = {
4040
'unicorn/no-null': 'error',
4141
'unicorn/no-object-as-default-parameter': 'error',
4242
'unicorn/no-process-exit': 'error',
43+
'unicorn/no-single-promise-in-promise-methods': 'error',
4344
'unicorn/no-static-only-class': 'error',
4445
'unicorn/no-thenable': 'error',
4546
'unicorn/no-this-assignment': 'error',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Disallow passing single-element arrays to `Promise` methods
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs).
4+
5+
🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Passing a single-element array to `Promise.all()`, `Promise.any()`, or `Promise.race()` is likely a mistake.
11+
12+
## Fail
13+
14+
```js
15+
const foo = await Promise.all([promise]);
16+
```
17+
18+
```js
19+
const foo = await Promise.any([promise]);
20+
```
21+
22+
```js
23+
const foo = await Promise.race([promise]);
24+
```
25+
26+
```js
27+
const promise = Promise.all([nonPromise]);
28+
```
29+
30+
## Pass
31+
32+
```js
33+
const foo = await promise;
34+
```
35+
36+
```js
37+
const promise = Promise.resolve(nonPromise);
38+
```
39+
40+
```js
41+
const foo = await Promise.all(promises);
42+
```
43+
44+
```js
45+
const foo = await Promise.any([promise, anotherPromise]);
46+
```
47+
48+
```js
49+
const [{value: foo, reason: error}] = await Promise.allSettled([promise]);
50+
```

‎readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c
148148
| [no-null](docs/rules/no-null.md) | Disallow the use of the `null` literal. || 🔧 | 💡 |
149149
| [no-object-as-default-parameter](docs/rules/no-object-as-default-parameter.md) | Disallow the use of objects as default parameters. || | |
150150
| [no-process-exit](docs/rules/no-process-exit.md) | Disallow `process.exit()`. || | |
151+
| [no-single-promise-in-promise-methods](docs/rules/no-single-promise-in-promise-methods.md) | Disallow passing single-element arrays to `Promise` methods. || 🔧 | 💡 |
151152
| [no-static-only-class](docs/rules/no-static-only-class.md) | Disallow classes that only have static members. || 🔧 | |
152153
| [no-thenable](docs/rules/no-thenable.md) | Disallow `then` property. || | |
153154
| [no-this-assignment](docs/rules/no-this-assignment.md) | Disallow assigning `this` to a variable. || | |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
'use strict';
2+
const {
3+
isCommaToken,
4+
} = require('@eslint-community/eslint-utils');
5+
const {isMethodCall} = require('./ast/index.js');
6+
const {
7+
getParenthesizedText,
8+
isParenthesized,
9+
needsSemicolon,
10+
shouldAddParenthesesToAwaitExpressionArgument,
11+
} = require('./utils/index.js');
12+
13+
const MESSAGE_ID_ERROR = 'no-single-promise-in-promise-methods/error';
14+
const MESSAGE_ID_SUGGESTION_UNWRAP = 'no-single-promise-in-promise-methods/unwrap';
15+
const MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE = 'no-single-promise-in-promise-methods/use-promise-resolve';
16+
const messages = {
17+
[MESSAGE_ID_ERROR]: 'Wrapping single-element array with `Promise.{{method}}()` is unnecessary.',
18+
[MESSAGE_ID_SUGGESTION_UNWRAP]: 'Use the value directly.',
19+
[MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE]: 'Switch to `Promise.resolve(…)`.',
20+
};
21+
const METHODS = ['all', 'any', 'race'];
22+
23+
const isPromiseMethodCallWithSingleElementArray = node =>
24+
isMethodCall(node, {
25+
object: 'Promise',
26+
methods: METHODS,
27+
optionalMember: false,
28+
optionalCall: false,
29+
argumentsLength: 1,
30+
})
31+
&& node.arguments[0].type === 'ArrayExpression'
32+
&& node.arguments[0].elements.length === 1
33+
&& node.arguments[0].elements[0]
34+
&& node.arguments[0].elements[0].type !== 'SpreadElement';
35+
36+
const unwrapAwaitedCallExpression = (callExpression, sourceCode) => fixer => {
37+
const [promiseNode] = callExpression.arguments[0].elements;
38+
let text = getParenthesizedText(promiseNode, sourceCode);
39+
40+
if (
41+
!isParenthesized(promiseNode, sourceCode)
42+
&& shouldAddParenthesesToAwaitExpressionArgument(promiseNode)
43+
) {
44+
text = `(${text})`;
45+
}
46+
47+
// The next node is already behind a `CallExpression`, there should be no ASI problem
48+
49+
return fixer.replaceText(callExpression, text);
50+
};
51+
52+
const unwrapNonAwaitedCallExpression = (callExpression, sourceCode) => fixer => {
53+
const [promiseNode] = callExpression.arguments[0].elements;
54+
let text = getParenthesizedText(promiseNode, sourceCode);
55+
56+
if (
57+
!isParenthesized(promiseNode, sourceCode)
58+
// Since the original call expression can be anywhere, it's hard to tell if the promise
59+
// need to be parenthesized, but it's safe to add parentheses
60+
&& !(
61+
// Known cases that not need parentheses
62+
promiseNode.type === 'Identifier'
63+
|| promiseNode.type === 'MemberExpression'
64+
)
65+
) {
66+
text = `(${text})`;
67+
}
68+
69+
const previousToken = sourceCode.getTokenBefore(callExpression);
70+
if (needsSemicolon(previousToken, sourceCode, text)) {
71+
text = `;${text}`;
72+
}
73+
74+
return fixer.replaceText(callExpression, text);
75+
};
76+
77+
const switchToPromiseResolve = (callExpression, sourceCode) => function * (fixer) {
78+
/*
79+
```
80+
Promise.all([promise,])
81+
// ^^^ methodNameNode
82+
```
83+
*/
84+
const methodNameNode = callExpression.callee.property;
85+
yield fixer.replaceText(methodNameNode, 'resolve');
86+
87+
const [arrayExpression] = callExpression.arguments;
88+
/*
89+
```
90+
Promise.all([promise,])
91+
// ^ openingBracketToken
92+
```
93+
*/
94+
const openingBracketToken = sourceCode.getFirstToken(arrayExpression);
95+
/*
96+
```
97+
Promise.all([promise,])
98+
// ^ penultimateToken
99+
// ^ closingBracketToken
100+
```
101+
*/
102+
const [
103+
penultimateToken,
104+
closingBracketToken,
105+
] = sourceCode.getLastTokens(arrayExpression, 2);
106+
107+
yield fixer.remove(openingBracketToken);
108+
yield fixer.remove(closingBracketToken);
109+
110+
if (isCommaToken(penultimateToken)) {
111+
yield fixer.remove(penultimateToken);
112+
}
113+
};
114+
115+
/** @param {import('eslint').Rule.RuleContext} context */
116+
const create = context => ({
117+
CallExpression(callExpression) {
118+
if (!isPromiseMethodCallWithSingleElementArray(callExpression)) {
119+
return;
120+
}
121+
122+
const problem = {
123+
node: callExpression.arguments[0],
124+
messageId: MESSAGE_ID_ERROR,
125+
data: {
126+
method: callExpression.callee.property.name,
127+
},
128+
};
129+
130+
const {sourceCode} = context;
131+
132+
if (
133+
callExpression.parent.type === 'AwaitExpression'
134+
&& callExpression.parent.argument === callExpression
135+
) {
136+
problem.fix = unwrapAwaitedCallExpression(callExpression, sourceCode);
137+
return problem;
138+
}
139+
140+
problem.suggest = [
141+
{
142+
messageId: MESSAGE_ID_SUGGESTION_UNWRAP,
143+
fix: unwrapNonAwaitedCallExpression(callExpression, sourceCode),
144+
},
145+
{
146+
messageId: MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE,
147+
fix: switchToPromiseResolve(callExpression, sourceCode),
148+
},
149+
];
150+
151+
return problem;
152+
},
153+
});
154+
155+
/** @type {import('eslint').Rule.RuleModule} */
156+
module.exports = {
157+
create,
158+
meta: {
159+
type: 'suggestion',
160+
docs: {
161+
description: 'Disallow passing single-element arrays to `Promise` methods.',
162+
},
163+
fixable: 'code',
164+
hasSuggestions: true,
165+
messages,
166+
},
167+
};

‎rules/utils/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ module.exports = {
4545
isValueNotUsable: require('./is-value-not-usable.js'),
4646
needsSemicolon: require('./needs-semicolon.js'),
4747
shouldAddParenthesesToMemberExpressionObject: require('./should-add-parentheses-to-member-expression-object.js'),
48+
shouldAddParenthesesToAwaitExpressionArgument: require('./should-add-parentheses-to-await-expression-argument.js'),
4849
singular: require('./singular.js'),
4950
toLocation: require('./to-location.js'),
5051
getAncestor: require('./get-ancestor.js'),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
3+
/**
4+
Check if parentheses should be added to a `node` when it's used as `argument` of `AwaitExpression`.
5+
6+
@param {Node} node - The AST node to check.
7+
@returns {boolean}
8+
*/
9+
function shouldAddParenthesesToAwaitExpressionArgument(node) {
10+
return (
11+
node.type === 'SequenceExpression'
12+
|| node.type === 'YieldExpression'
13+
|| node.type === 'ArrowFunctionExpression'
14+
|| node.type === 'ConditionalExpression'
15+
|| node.type === 'AssignmentExpression'
16+
|| node.type === 'LogicalExpression'
17+
|| node.type === 'BinaryExpression'
18+
);
19+
}
20+
21+
module.exports = shouldAddParenthesesToAwaitExpressionArgument;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import outdent from 'outdent';
2+
import {getTester} from './utils/test.mjs';
3+
4+
const {test} = getTester(import.meta);
5+
6+
// `await`ed
7+
test.snapshot({
8+
valid: [
9+
],
10+
invalid: [
11+
'await Promise.all([(0, promise)])',
12+
'async function * foo() {await Promise.all([yield promise])}',
13+
'async function * foo() {await Promise.all([yield* promise])}',
14+
'await Promise.all([() => promise,],)',
15+
'await Promise.all([a ? b : c,],)',
16+
'await Promise.all([x ??= y,],)',
17+
'await Promise.all([x ||= y,],)',
18+
'await Promise.all([x &&= y,],)',
19+
'await Promise.all([x |= y,],)',
20+
'await Promise.all([x ^= y,],)',
21+
'await Promise.all([x ??= y,],)',
22+
'await Promise.all([x ||= y,],)',
23+
'await Promise.all([x &&= y,],)',
24+
'await Promise.all([x | y,],)',
25+
'await Promise.all([x ^ y,],)',
26+
'await Promise.all([x & y,],)',
27+
'await Promise.all([x !== y,],)',
28+
'await Promise.all([x == y,],)',
29+
'await Promise.all([x in y,],)',
30+
'await Promise.all([x >>> y,],)',
31+
'await Promise.all([x + y,],)',
32+
'await Promise.all([x / y,],)',
33+
'await Promise.all([x ** y,],)',
34+
'await Promise.all([promise,],)',
35+
'await Promise.all([getPromise(),],)',
36+
'await Promise.all([promises[0],],)',
37+
'await Promise.all([await promise])',
38+
'await Promise.any([promise])',
39+
'await Promise.race([promise])',
40+
'await Promise.all([new Promise(() => {})])',
41+
'+await Promise.all([+1])',
42+
43+
// ASI, `Promise.all()` is not really `await`ed
44+
outdent`
45+
await Promise.all([(x,y)])
46+
[0].toString()
47+
`,
48+
],
49+
});
50+
51+
// Not `await`ed
52+
test.snapshot({
53+
valid: [
54+
'Promise.all([promise, anotherPromise])',
55+
'Promise.all(notArrayLiteral)',
56+
'Promise.all([...promises])',
57+
'Promise.any([promise, anotherPromise])',
58+
'Promise.race([promise, anotherPromise])',
59+
'Promise.notListedMethod([promise])',
60+
'Promise[all]([promise])',
61+
'Promise.all([,])',
62+
'NotPromise.all([promise])',
63+
'Promise?.all([promise])',
64+
'Promise.all?.([promise])',
65+
'Promise.all(...[promise])',
66+
'Promise.all([promise], extraArguments)',
67+
'Promise.all()',
68+
'new Promise.all([promise])',
69+
70+
// We are not checking these cases
71+
'globalThis.Promise.all([promise])',
72+
'Promise["all"]([promise])',
73+
74+
// This can't be checked
75+
'Promise.allSettled([promise])',
76+
],
77+
invalid: [
78+
'Promise.all([promise,],)',
79+
outdent`
80+
foo
81+
Promise.all([(0, promise),],)
82+
`,
83+
outdent`
84+
foo
85+
Promise.all([[array][0],],)
86+
`,
87+
'Promise.all([promise]).then()',
88+
'Promise.all([1]).then()',
89+
'Promise.all([1.]).then()',
90+
'Promise.all([.1]).then()',
91+
'Promise.all([(0, promise)]).then()',
92+
'const _ = () => Promise.all([ a ?? b ,],)',
93+
'Promise.all([ {a} = 1 ,],)',
94+
'Promise.all([ function () {} ,],)',
95+
'Promise.all([ class {} ,],)',
96+
'Promise.all([ new Foo ,],).then()',
97+
'Promise.all([ new Foo ,],).toString',
98+
'foo(Promise.all([promise]))',
99+
'Promise.all([promise]).foo = 1',
100+
'Promise.all([promise])[0] ||= 1',
101+
'Promise.all([undefined]).then()',
102+
'Promise.all([null]).then()',
103+
],
104+
});

‎test/snapshots/no-single-promise-in-promise-methods.mjs.md

+1,128
Large diffs are not rendered by default.
Binary file not shown.

0 commit comments

Comments
 (0)
Please sign in to comment.