diff --git a/packages/eslint-plugin/src/rules/no-misused-promises.ts b/packages/eslint-plugin/src/rules/no-misused-promises.ts index 6e42e547436..7f08402334c 100644 --- a/packages/eslint-plugin/src/rules/no-misused-promises.ts +++ b/packages/eslint-plugin/src/rules/no-misused-promises.ts @@ -11,7 +11,15 @@ type Options = [ }, ]; -export default util.createRule({ +type MessageId = + | 'conditional' + | 'voidReturnArgument' + | 'voidReturnVariable' + | 'voidReturnProperty' + | 'voidReturnReturnValue' + | 'voidReturnAttribute'; + +export default util.createRule({ name: 'no-misused-promises', meta: { docs: { @@ -20,8 +28,16 @@ export default util.createRule({ requiresTypeChecking: true, }, messages: { - voidReturn: + voidReturnArgument: 'Promise returned in function argument where a void return was expected.', + voidReturnVariable: + 'Promise-returning function provided to variable where a void return was expected.', + voidReturnProperty: + 'Promise-returning function provided to property where a void return was expected.', + voidReturnReturnValue: + 'Promise-returning function provided to return value where a void return was expected.', + voidReturnAttribute: + 'Promise-returning function provided to attribute where a void return was expected.', conditional: 'Expected non-Promise value in a boolean conditional.', }, schema: [ @@ -67,6 +83,11 @@ export default util.createRule({ const voidReturnChecks: TSESLint.RuleListener = { CallExpression: checkArguments, NewExpression: checkArguments, + AssignmentExpression: checkAssignment, + VariableDeclarator: checkVariableDeclaration, + Property: checkProperty, + ReturnStatement: checkReturnStatement, + JSXAttribute: checkJSXAttribute, }; function checkTestConditional(node: { @@ -130,13 +151,168 @@ export default util.createRule({ const tsNode = parserServices.esTreeNodeToTSNodeMap.get(argument); if (returnsThenable(checker, tsNode as ts.Expression)) { context.report({ - messageId: 'voidReturn', + messageId: 'voidReturnArgument', node: argument, }); } } } + function checkAssignment(node: TSESTree.AssignmentExpression): void { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + const varType = checker.getTypeAtLocation(tsNode.left); + if (!isVoidReturningFunctionType(checker, tsNode.left, varType)) { + return; + } + + if (returnsThenable(checker, tsNode.right)) { + context.report({ + messageId: 'voidReturnVariable', + node: node.right, + }); + } + } + + function checkVariableDeclaration(node: TSESTree.VariableDeclarator): void { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + if (tsNode.initializer === undefined || node.init === null) { + return; + } + const varType = checker.getTypeAtLocation(tsNode.name); + if (!isVoidReturningFunctionType(checker, tsNode.initializer, varType)) { + return; + } + + if (returnsThenable(checker, tsNode.initializer)) { + context.report({ + messageId: 'voidReturnVariable', + node: node.init, + }); + } + } + + function checkProperty(node: TSESTree.Property): void { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + if (ts.isPropertyAssignment(tsNode)) { + const contextualType = checker.getContextualType(tsNode.initializer); + if ( + contextualType !== undefined && + isVoidReturningFunctionType( + checker, + tsNode.initializer, + contextualType, + ) && + returnsThenable(checker, tsNode.initializer) + ) { + context.report({ + messageId: 'voidReturnProperty', + node: node.value, + }); + } + } else if (ts.isShorthandPropertyAssignment(tsNode)) { + const contextualType = checker.getContextualType(tsNode.name); + if ( + contextualType !== undefined && + isVoidReturningFunctionType(checker, tsNode.name, contextualType) && + returnsThenable(checker, tsNode.name) + ) { + context.report({ + messageId: 'voidReturnProperty', + node: node.value, + }); + } + } else if (ts.isMethodDeclaration(tsNode)) { + if (ts.isComputedPropertyName(tsNode.name)) { + return; + } + const obj = tsNode.parent; + + // Below condition isn't satisfied unless something goes wrong, + // but is needed for type checking. + // 'node' does not include class method declaration so 'obj' is + // always an object literal expression, but after converting 'node' + // to TypeScript AST, its type includes MethodDeclaration which + // does include the case of class method declaration. + if (!ts.isObjectLiteralExpression(obj)) { + return; + } + + const objType = checker.getContextualType(obj); + if (objType === undefined) { + return; + } + const propertySymbol = checker.getPropertyOfType( + objType, + tsNode.name.text, + ); + if (propertySymbol === undefined) { + return; + } + + const contextualType = checker.getTypeOfSymbolAtLocation( + propertySymbol, + tsNode.name, + ); + + if ( + isVoidReturningFunctionType(checker, tsNode.name, contextualType) && + returnsThenable(checker, tsNode) + ) { + context.report({ + messageId: 'voidReturnProperty', + node: node.value, + }); + } + return; + } + } + + function checkReturnStatement(node: TSESTree.ReturnStatement): void { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + if (tsNode.expression === undefined || node.argument === null) { + return; + } + const contextualType = checker.getContextualType(tsNode.expression); + if ( + contextualType !== undefined && + isVoidReturningFunctionType( + checker, + tsNode.expression, + contextualType, + ) && + returnsThenable(checker, tsNode.expression) + ) { + context.report({ + messageId: 'voidReturnReturnValue', + node: node.argument, + }); + } + } + + function checkJSXAttribute(node: TSESTree.JSXAttribute): void { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + const value = tsNode.initializer; + if ( + node.value === null || + value === undefined || + !ts.isJsxExpression(value) || + value.expression === undefined + ) { + return; + } + const contextualType = checker.getContextualType(value); + if ( + contextualType !== undefined && + isVoidReturningFunctionType(checker, value, contextualType) && + returnsThenable(checker, value.expression) + ) { + context.report({ + messageId: 'voidReturnAttribute', + node: node.value, + }); + } + } + return { ...(checksConditionals ? conditionalChecks : {}), ...(checksVoidReturn ? voidReturnChecks : {}), @@ -219,7 +395,6 @@ function voidFunctionParams( node: ts.CallExpression | ts.NewExpression, ): Set { const voidReturnIndices = new Set(); - const thenableReturnIndices = new Set(); const type = checker.getTypeAtLocation(node.expression); for (const subType of tsutils.unionTypeParts(type)) { @@ -233,36 +408,41 @@ function voidFunctionParams( parameter, node.expression, ); - for (const subType of tsutils.unionTypeParts(type)) { - for (const signature of subType.getCallSignatures()) { - const returnType = signature.getReturnType(); - if (tsutils.isTypeFlagSet(returnType, ts.TypeFlags.Void)) { - voidReturnIndices.add(index); - } else if ( - tsutils.isThenableType(checker, node.expression, returnType) - ) { - thenableReturnIndices.add(index); - } - } + if (isVoidReturningFunctionType(checker, node.expression, type)) { + voidReturnIndices.add(index); } } } } - // If a certain positional argument accepts both thenable and void returns, - // a promise-returning function is valid - for (const thenable of thenableReturnIndices) { - voidReturnIndices.delete(thenable); - } - return voidReturnIndices; } -// Returns true if the expression is a function that returns a thenable -function returnsThenable( +// Returns true if given type is a void-returning function. +function isVoidReturningFunctionType( checker: ts.TypeChecker, - node: ts.Expression, + node: ts.Node, + type: ts.Type, ): boolean { + let hasVoidReturningFunction = false; + let hasThenableReturningFunction = false; + for (const subType of tsutils.unionTypeParts(type)) { + for (const signature of subType.getCallSignatures()) { + const returnType = signature.getReturnType(); + if (tsutils.isTypeFlagSet(returnType, ts.TypeFlags.Void)) { + hasVoidReturningFunction = true; + } else if (tsutils.isThenableType(checker, node, returnType)) { + hasThenableReturningFunction = true; + } + } + } + // If a certain positional argument accepts both thenable and void returns, + // a promise-returning function is valid + return hasVoidReturningFunction && !hasThenableReturningFunction; +} + +// Returns true if the expression is a function that returns a thenable +function returnsThenable(checker: ts.TypeChecker, node: ts.Node): boolean { const type = checker.getApparentType(checker.getTypeAtLocation(node)); for (const subType of tsutils.unionTypeParts(type)) { diff --git a/packages/eslint-plugin/tests/rules/no-misused-promises.test.ts b/packages/eslint-plugin/tests/rules/no-misused-promises.test.ts index 63e429000c0..961a21d4c89 100644 --- a/packages/eslint-plugin/tests/rules/no-misused-promises.test.ts +++ b/packages/eslint-plugin/tests/rules/no-misused-promises.test.ts @@ -166,6 +166,92 @@ async function test(p: Promise | undefined) { } } `, + ` +let f; +f = async () => 10; + `, + ` +let f: () => Promise; +f = async () => 10; +const g = async () => 0; +const h: () => Promise = async () => 10; + `, + ` +const obj = { + f: async () => 10, +}; + `, + ` +const f = async () => 123; +const obj = { + f, +}; + `, + ` +const obj = { + async f() { + return 0; + }, +}; + `, + ` +type O = { f: () => Promise; g: () => Promise }; +const g = async () => 0; +const obj: O = { + f: async () => 10, + g, +}; + `, + ` +type O = { f: () => Promise }; +const name = 'f'; +const obj: O = { + async [name]() { + return 10; + }, +}; + `, + ` +const obj: number = { + g() { + return 10; + }, +}; + `, + ` +const obj = { + f: async () => 'foo', + async g() { + return 0; + }, +}; + `, + ` +function f() { + return async () => 0; +} +function g() { + return; +} + `, + { + code: ` +type O = { + bool: boolean; + func: () => Promise; +}; +const Component = (obj: O) => null; + 10} />; + `, + filename: 'react.tsx', + }, + { + code: ` +const Component: any = () => null; + 10} />; + `, + filename: 'react.tsx', + }, ], invalid: [ @@ -265,7 +351,7 @@ if (!Promise.resolve()) { errors: [ { line: 2, - messageId: 'voidReturn', + messageId: 'voidReturnArgument', }, ], }, @@ -279,7 +365,7 @@ new Promise(async (resolve, reject) => { errors: [ { line: 2, - messageId: 'voidReturn', + messageId: 'voidReturnArgument', }, ], }, @@ -296,7 +382,7 @@ fnWithCallback('val', async (err, res) => { errors: [ { line: 6, - messageId: 'voidReturn', + messageId: 'voidReturnArgument', }, ], }, @@ -311,7 +397,7 @@ fnWithCallback('val', (err, res) => Promise.resolve(res)); errors: [ { line: 6, - messageId: 'voidReturn', + messageId: 'voidReturnArgument', }, ], }, @@ -332,7 +418,7 @@ fnWithCallback('val', (err, res) => { errors: [ { line: 6, - messageId: 'voidReturn', + messageId: 'voidReturnArgument', }, ], }, @@ -349,7 +435,7 @@ fnWithCallback?.('val', (err, res) => Promise.resolve(res)); errors: [ { line: 8, - messageId: 'voidReturn', + messageId: 'voidReturnArgument', }, ], }, @@ -372,7 +458,7 @@ fnWithCallback('val', (err, res) => { errors: [ { line: 8, - messageId: 'voidReturn', + messageId: 'voidReturnArgument', }, ], }, @@ -432,5 +518,174 @@ function test(p: Promise | undefined) { }, ], }, + { + code: ` +let f: () => void; +f = async () => { + return 3; +}; + `, + errors: [ + { + line: 3, + messageId: 'voidReturnVariable', + }, + ], + }, + { + code: ` +const f: () => void = async () => { + return 0; +}; +const g = async () => 1, + h: () => void = async () => {}; + `, + errors: [ + { + line: 2, + messageId: 'voidReturnVariable', + }, + { + line: 6, + messageId: 'voidReturnVariable', + }, + ], + }, + { + code: ` +const obj: { + f?: () => void; +} = {}; +obj.f = async () => { + return 0; +}; + `, + errors: [ + { + line: 5, + messageId: 'voidReturnVariable', + }, + ], + }, + { + code: ` +type O = { f: () => void }; +const obj: O = { + f: async () => 'foo', +}; + `, + errors: [ + { + line: 4, + messageId: 'voidReturnProperty', + }, + ], + }, + { + code: ` +type O = { f: () => void }; +const f = async () => 0; +const obj: O = { + f, +}; + `, + errors: [ + { + line: 5, + messageId: 'voidReturnProperty', + }, + ], + }, + { + code: ` +type O = { f: () => void }; +const obj: O = { + async f() { + return 0; + }, +}; + `, + errors: [ + { + line: 4, + messageId: 'voidReturnProperty', + }, + ], + }, + { + code: ` +type O = { f: () => void; g: () => void; h: () => void }; +function f(): O { + const h = async () => 0; + return { + async f() { + return 123; + }, + g: async () => 0, + h, + }; +} + `, + errors: [ + { + line: 6, + messageId: 'voidReturnProperty', + }, + { + line: 9, + messageId: 'voidReturnProperty', + }, + { + line: 10, + messageId: 'voidReturnProperty', + }, + ], + }, + { + code: ` +function f(): () => void { + return async () => 0; +} + `, + errors: [ + { + line: 3, + messageId: 'voidReturnReturnValue', + }, + ], + }, + { + code: ` +type O = { + func: () => void; +}; +const Component = (obj: O) => null; + 0} />; + `, + filename: 'react.tsx', + errors: [ + { + line: 6, + messageId: 'voidReturnAttribute', + }, + ], + }, + { + code: ` +type O = { + func: () => void; +}; +const g = async () => 'foo'; +const Component = (obj: O) => null; +; + `, + filename: 'react.tsx', + errors: [ + { + line: 7, + messageId: 'voidReturnAttribute', + }, + ], + }, ], }); diff --git a/packages/website/src/components/OptionsSelector.tsx b/packages/website/src/components/OptionsSelector.tsx index 83715fc3ae5..86c84296507 100644 --- a/packages/website/src/components/OptionsSelector.tsx +++ b/packages/website/src/components/OptionsSelector.tsx @@ -74,17 +74,21 @@ function OptionsSelector({ [setState], ); - const copyLinkToClipboard = useCallback(async () => { - await navigator.clipboard.writeText(document.location.toString()); - setCopyLink(true); + const copyLinkToClipboard = useCallback(() => { + void navigator.clipboard + .writeText(document.location.toString()) + .then(() => { + setCopyLink(true); + }); }, []); - const copyMarkdownToClipboard = useCallback(async () => { + const copyMarkdownToClipboard = useCallback(() => { if (isLoading) { return; } - await navigator.clipboard.writeText(createMarkdown(state)); - setCopyMarkdown(true); + void navigator.clipboard.writeText(createMarkdown(state)).then(() => { + setCopyMarkdown(true); + }); }, [state, isLoading]); const openIssue = useCallback(() => {