Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): [no-floating-promises] flag result of .map(async) #7897

Merged
Merged
220 changes: 154 additions & 66 deletions packages/eslint-plugin/src/rules/no-floating-promises.ts
kirkwaiblinger marked this conversation as resolved.
Show resolved Hide resolved
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
Expand Up @@ -23,7 +23,9 @@ type MessageId =
| 'floatingUselessRejectionHandler'
| 'floatingUselessRejectionHandlerVoid'
| 'floatingFixAwait'
| 'floatingFixVoid';
| 'floatingFixVoid'
| 'floatingPromiseArray'
| 'floatingPromiseArrayVoid';

const messageBase =
'Promises must be awaited, end with a call to .catch, or end with a call to .then with a rejection handler.';
Expand All @@ -35,6 +37,13 @@ const messageBaseVoid =
const messageRejectionHandler =
'A rejection handler that is not a function will be ignored.';

const messagePromiseArray =
"An array of Promises may be unintentional. Consider handling the promises' fulfillment or rejection with Promise.all or similar.";
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

const messagePromiseArrayVoid =
"An array of Promises may be unintentional. Consider handling the promises' fulfillment or rejection with Promise.all or similar," +
' or explicitly marking the expression as ignored with the `void` operator.';

export default createRule<Options, MessageId>({
name: 'no-floating-promises',
meta: {
Expand All @@ -54,6 +63,9 @@ export default createRule<Options, MessageId>({
messageBase + ' ' + messageRejectionHandler,
floatingUselessRejectionHandlerVoid:
messageBaseVoid + ' ' + messageRejectionHandler,

kirkwaiblinger marked this conversation as resolved.
Show resolved Hide resolved
floatingPromiseArray: messagePromiseArray,
floatingPromiseArrayVoid: messagePromiseArrayVoid,
},
schema: [
{
Expand Down Expand Up @@ -97,74 +109,81 @@ export default createRule<Options, MessageId>({
expression = expression.expression;
}

const { isUnhandled, nonFunctionHandler } = isUnhandledPromise(
checker,
expression,
);
const { isUnhandled, nonFunctionHandler, promiseArray } =
isUnhandledPromise(checker, expression);

if (isUnhandled) {
if (options.ignoreVoid) {
context.report({
node,
messageId: nonFunctionHandler
? 'floatingUselessRejectionHandlerVoid'
: 'floatingVoid',
suggest: [
{
messageId: 'floatingFixVoid',
fix(fixer): TSESLint.RuleFix | TSESLint.RuleFix[] {
const tsNode = services.esTreeNodeToTSNodeMap.get(
node.expression,
);
if (isHigherPrecedenceThanUnary(tsNode)) {
return fixer.insertTextBefore(node, 'void ');
}
return [
fixer.insertTextBefore(node, 'void ('),
fixer.insertTextAfterRange(
[expression.range[1], expression.range[1]],
')',
),
];
if (!promiseArray) {
if (options.ignoreVoid) {
context.report({
node,
messageId: nonFunctionHandler
? 'floatingUselessRejectionHandlerVoid'
: 'floatingVoid',
suggest: [
{
messageId: 'floatingFixVoid',
fix(fixer): TSESLint.RuleFix | TSESLint.RuleFix[] {
const tsNode = services.esTreeNodeToTSNodeMap.get(
node.expression,
);
if (isHigherPrecedenceThanUnary(tsNode)) {
return fixer.insertTextBefore(node, 'void ');
}
return [
fixer.insertTextBefore(node, 'void ('),
fixer.insertTextAfterRange(
[expression.range[1], expression.range[1]],
')',
),
];
},
},
},
],
});
],
});
} else {
context.report({
node,
messageId: nonFunctionHandler
? 'floatingUselessRejectionHandler'
: 'floating',
suggest: [
{
messageId: 'floatingFixAwait',
fix(fixer): TSESLint.RuleFix | TSESLint.RuleFix[] {
if (
expression.type === AST_NODE_TYPES.UnaryExpression &&
expression.operator === 'void'
) {
return fixer.replaceTextRange(
[expression.range[0], expression.range[0] + 4],
'await',
);
}
const tsNode = services.esTreeNodeToTSNodeMap.get(
node.expression,
);
if (isHigherPrecedenceThanUnary(tsNode)) {
return fixer.insertTextBefore(node, 'await ');
}
return [
fixer.insertTextBefore(node, 'await ('),
fixer.insertTextAfterRange(
[expression.range[1], expression.range[1]],
')',
),
];
},
},
],
});
}
} else {
context.report({
node,
messageId: nonFunctionHandler
? 'floatingUselessRejectionHandler'
: 'floating',
suggest: [
{
messageId: 'floatingFixAwait',
fix(fixer): TSESLint.RuleFix | TSESLint.RuleFix[] {
if (
expression.type === AST_NODE_TYPES.UnaryExpression &&
expression.operator === 'void'
) {
return fixer.replaceTextRange(
[expression.range[0], expression.range[0] + 4],
'await',
);
}
const tsNode = services.esTreeNodeToTSNodeMap.get(
node.expression,
);
if (isHigherPrecedenceThanUnary(tsNode)) {
return fixer.insertTextBefore(node, 'await ');
}
return [
fixer.insertTextBefore(node, 'await ('),
fixer.insertTextAfterRange(
[expression.range[1], expression.range[1]],
')',
),
];
},
},
],
messageId: options.ignoreVoid
? 'floatingPromiseArrayVoid'
: 'floatingPromiseArray',
});
}
}
Expand Down Expand Up @@ -206,7 +225,11 @@ export default createRule<Options, MessageId>({
function isUnhandledPromise(
checker: ts.TypeChecker,
node: TSESTree.Node,
): { isUnhandled: boolean; nonFunctionHandler?: boolean } {
): {
isUnhandled: boolean;
nonFunctionHandler?: boolean;
promiseArray?: boolean;
} {
// First, check expressions whose resulting types may not be promise-like
if (node.type === AST_NODE_TYPES.SequenceExpression) {
// Any child in a comma expression could return a potentially unhandled
Expand All @@ -229,8 +252,14 @@ export default createRule<Options, MessageId>({
return isUnhandledPromise(checker, node.argument);
}

if (isPromiseArray(checker, services.esTreeNodeToTSNodeMap.get(node))) {
return { isUnhandled: true, promiseArray: true };
}

// Check the type. At this point it can't be unhandled if it isn't a promise
if (!isPromiseLike(checker, services.esTreeNodeToTSNodeMap.get(node))) {
if (
!isPromiseLikeNode(checker, services.esTreeNodeToTSNodeMap.get(node))
) {
return { isUnhandled: false };
}

Expand Down Expand Up @@ -296,11 +325,70 @@ export default createRule<Options, MessageId>({
},
});

function isPromiseArray(checker: ts.TypeChecker, node: ts.Node): boolean {
const type = checker.getTypeAtLocation(node);
if (checker.isArrayType(type)) {
const arrayType = checker.getTypeArguments(type)[0];
if (isPromiseLikeType(checker, arrayType)) {
return true;
}
}

return false;
}

function isPromiseLikeType(checker: ts.TypeChecker, type: ts.Type): boolean {
// Get the apparent type, which considers type aliases.
const apparentType = checker.getApparentType(type);

// is any possible union type Promise-like?
return tsutils.unionTypeParts(apparentType).some(typeUnionComponent => {
// Must have 'then' property to be Promise-like.
const thenProperty = typeUnionComponent.getProperty('then');
if (!thenProperty) {
return false;
}

// Does the 'then' property have any fitting call signature?
const hasFittingCallSignature = checker
.getTypeOfSymbol(thenProperty)
.getCallSignatures()
.some(signature => {
// Call signature must have onFulfilled and onRejected parameters.
if (!(signature.parameters.length >= 2)) {
return false;
}

const [onFulFilled, onRejected] = signature.parameters;

// Can each handler parameter accept a callable?
const bothParametersAreCallable = [onFulFilled, onRejected].every(
handler => {
const handlerType = checker.getTypeOfSymbol(handler);

const acceptsCallable = tsutils
.unionTypeParts(handlerType)
.some(
handlerTypeUnionComponent =>
handlerTypeUnionComponent.getCallSignatures().length !== 0,
);

return acceptsCallable;
},
);

return bothParametersAreCallable;
});

return hasFittingCallSignature;
});
}

// Modified from tsutils.isThenable() to only consider thenables which can be
// rejected/caught via a second parameter. Original source (MIT licensed):
//
// https://github.com/ajafff/tsutils/blob/49d0d31050b44b81e918eae4fbaf1dfe7b7286af/util/type.ts#L95-L125
function isPromiseLike(checker: ts.TypeChecker, node: ts.Node): boolean {
function isPromiseLikeNode(checker: ts.TypeChecker, node: ts.Node): boolean {
const type = checker.getTypeAtLocation(node);
for (const ty of tsutils.unionTypeParts(checker.getApparentType(type))) {
const then = ty.getProperty('then');
Expand Down
77 changes: 74 additions & 3 deletions packages/eslint-plugin/tests/rules/no-floating-promises.test.ts
Expand Up @@ -222,7 +222,7 @@ async function test() {
`
async function test() {
class Thenable {
then(callback: () => {}): Thenable {
then(callback: () => void): Thenable {
return new Thenable();
}
}
Expand Down Expand Up @@ -264,7 +264,7 @@ async function test() {
`
async function test() {
class CatchableThenable {
then(callback: () => {}, callback: () => {}): CatchableThenable {
then(callback: () => void, callback: () => void): CatchableThenable {
return new CatchableThenable();
}
}
Expand Down Expand Up @@ -475,6 +475,35 @@ Promise.reject()
.finally(() => {});
`,
},
{
code: `
await Promise.all([Promise.resolve(), Promise.resolve()]);
`,
},
{
code: `
declare const promiseArray: Array<Promise<unknown>>;
void promiseArray;
`,
},
{
// This one is a bit of a head-scratcher, knowing that the rule recursively
// checks .finally() expressions. However, ultimately, the whole expression
// is not a promise (it's invalid TS), so it's not a floating promise.
code: `
kirkwaiblinger marked this conversation as resolved.
Show resolved Hide resolved
[Promise.reject(), Promise.reject()].finally(() => {});
`,
},
{
code: `
[Promise.reject(), Promise.reject()].then(() => {});
`,
},
{
code: `
[1, 2, void Promise.reject(), 3];
`,
},
],

invalid: [
Expand Down Expand Up @@ -997,7 +1026,7 @@ async function test() {
code: `
async function test() {
class CatchableThenable {
then(callback: () => {}, callback: () => {}): CatchableThenable {
then(callback: () => void, callback: () => void): CatchableThenable {
return new CatchableThenable();
}
}
Expand Down Expand Up @@ -1651,5 +1680,47 @@ Promise.reject(new Error('message')).finally(() => {});
`,
errors: [{ line: 2, messageId: 'floatingVoid' }],
},
{
code: `
[1, 2, 3].map(() => Promise.reject());
`,
errors: [{ line: 2, messageId: 'floatingPromiseArrayVoid' }],
},
{
code: `
declare const array: unknown[];
array.map(() => Promise.reject());
`,
errors: [{ line: 3, messageId: 'floatingPromiseArrayVoid' }],
},
{
code: `
declare const promiseArray: Array<Promise<unknown>>;
void promiseArray;
`,
options: [{ ignoreVoid: false }],
errors: [{ line: 3, messageId: 'floatingPromiseArray' }],
},
{
code: `
[1, 2, Promise.reject(), 3];
`,
errors: [{ line: 2, messageId: 'floatingPromiseArrayVoid' }],
},
{
code: `
[1, 2, Promise.reject().catch(() => {}), 3];
`,
errors: [{ line: 2, messageId: 'floatingPromiseArrayVoid' }],
},
{
code: `
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test case from the issue report

const data = ['test'];
data.map(async () => {
await new Promise((_res, rej) => setTimeout(rej, 1000));
});
`,
errors: [{ line: 3, messageId: 'floatingPromiseArrayVoid' }],
},
],
});