UNPKG

eslint-plugin-listeners

Version:

ESLint plugin for preventing memory leaks around event listeners

250 lines (225 loc) 7.87 kB
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { isNodeMemberExpression, isNodeFunctionExpression, isNodeArrowFunctionExpression, isNodeIdentifier, parseMemberExpression, RuleType, getDescription, isArgumentLiteral, isArgumentIdentifier, } from '../utils'; enum ListenerType { ADD_EVENT_LISTENER = 'addEventListener', REMOVE_EVENT_LISTENER = 'removeEventListener', ADD_LISTENER = 'addListener', REMOVE_LISTENER = 'removeListener', REMOVE_ALL_LISTENERS = 'removeAllListeners', ON_LISTENER = 'on', OFF_LISTENER = 'off', } const ADD_LISTENERS = [ListenerType.ADD_EVENT_LISTENER, ListenerType.ADD_LISTENER, ListenerType.ON_LISTENER]; type ADD_LISTENERS_TYPE = (typeof ADD_LISTENERS)[number]; const REMOVE_LISTENERS = [ListenerType.REMOVE_EVENT_LISTENER, ListenerType.REMOVE_LISTENER, ListenerType.OFF_LISTENER]; type REMOVE_LISTENERS_TYPE = (typeof REMOVE_LISTENERS)[number]; const PLAIN_FUNCTION = 'plain function'; const ARROW_FUNCTION = 'arrow function'; const isProhibitedHandler = (type: string) => type === PLAIN_FUNCTION || type === ARROW_FUNCTION; interface ListenedElements { [key: string]: { [key: string]: { func: string; loc: TSESTree.SourceLocation; isOnce?: boolean; hasUseCapture?: boolean; }; }; } type Listeners = { [key in ADD_LISTENERS_TYPE | REMOVE_LISTENERS_TYPE | ListenerType.REMOVE_ALL_LISTENERS]?: ListenedElements; }; interface HandlerParameters { properties: { key: { name: string; }; value: { value: boolean; }; }[]; } const reportMissingListener = ( context: TSESLint.RuleContext<string, Array<unknown>>, element: string, eventName: string, loc: TSESTree.SourceLocation ) => { context.report({ loc, messageId: 'missingRemoveEventListener', data: { eventName, element, }, }); }; const reportListenersDoNoMatch = ( context: TSESLint.RuleContext<string, unknown[]>, element: string, eventName: string, add: string, remove: string, loc: TSESTree.SourceLocation, isUseCapture: boolean ) => { if (isUseCapture) { return context.report({ loc, messageId: 'listenersDoNotMatchUseCapture', data: { remove, element, eventName, }, }); } context.report({ loc, messageId: 'listenersDoNotMatch', data: { eventName, element, add, remove, }, }); }; const reportProhibitedListener = ( context: TSESLint.RuleContext<string, unknown[]>, element: string, eventName: string, type: string, loc: TSESTree.SourceLocation ) => { context.report({ loc, messageId: 'prohibitedListener', data: { eventName, element, type, }, }); }; const callExpressionListener = (listeners: Listeners) => (node: TSESTree.CallExpression) => { if (isNodeMemberExpression(node.callee)) { const callee: TSESTree.MemberExpression = <TSESTree.MemberExpression>node.callee; const listenerType = (<TSESTree.Identifier>callee.property)?.name as ListenerType; if ([...ADD_LISTENERS, ...REMOVE_LISTENERS, ListenerType.REMOVE_ALL_LISTENERS].includes(listenerType)) { const element = parseMemberExpression(callee); const eventName = ListenerType.REMOVE_ALL_LISTENERS !== listenerType ? (<TSESTree.Literal | undefined>node.arguments?.[0])?.value : '_all_'; const handler = <TSESTree.Expression>node.arguments[1]; if (listenerType === ListenerType.ADD_EVENT_LISTENER) { const params = (node.arguments?.[2] as HandlerParameters)?.properties; if (params && params.length) { const isOnce = params.some((param) => param.key.name === 'once' && param.value.value); if (isOnce) { return; } } } if (!eventName) { return; } let func: string; if (isNodeFunctionExpression(handler)) { func = PLAIN_FUNCTION; } else if (isNodeArrowFunctionExpression(handler)) { func = ARROW_FUNCTION; } else if (isNodeIdentifier(handler)) { func = (<TSESTree.Identifier>handler).name; } else if (handler) { func = parseMemberExpression(<TSESTree.MemberExpression>handler); } const currentTypeListeners = listeners[<ListenerType>listenerType] || {}; listeners[<ListenerType>listenerType] = { ...currentTypeListeners, [element]: { ...currentTypeListeners[element], [<string>eventName]: { func, loc: node.loc, hasUseCapture: isArgumentLiteral(node.arguments[2]) || isArgumentIdentifier(node.arguments[2]), }, }, }; } } }; const programListener = (ruleName: RuleType, listeners: Listeners, context: TSESLint.RuleContext<string, unknown[]>) => () => { const addListenersPre = ADD_LISTENERS.map((listenerName) => listeners[listenerName] ?? {}); const addListeners: ListenedElements = Object.assign({}, ...addListenersPre); const removeListenersPre = REMOVE_LISTENERS.map((listerName) => listeners[listerName] ?? {}); const removeListeners: ListenedElements = Object.assign({}, ...removeListenersPre); const removeAllListeners = listeners[ListenerType.REMOVE_ALL_LISTENERS] ?? {}; Object.keys(addListeners).forEach((element) => { const addEvents = addListeners[element]; const removeEvents = removeListeners[element]; const removeAllEvents = removeAllListeners[element]; Object.entries(addEvents).forEach(([eventName, { func, loc, hasUseCapture }]) => { const event = removeEvents?.[eventName] ?? removeAllEvents; switch (ruleName) { case RuleType.MissingRemoveEventListener: if (!event) { reportMissingListener(context, element, eventName, loc); } break; case RuleType.InlineFunctionEventListener: if (isProhibitedHandler(func) && (!event || event.func !== undefined)) { reportProhibitedListener(context, element, eventName, func, loc); } break; case RuleType.MatchingRemoveEventListener: if (event && event.func !== func && event.func !== undefined && typeof event.func === 'string') { reportListenersDoNoMatch(context, element, eventName, func, event.func, loc, false); } if (event && event.func === func && hasUseCapture && !event.hasUseCapture && event.func !== undefined) { reportListenersDoNoMatch(context, element, eventName, func, event.func, loc, hasUseCapture); } break; } }); }); }; export const createRule = (ruleName: RuleType): TSESLint.RuleModule<string, unknown[]> => ({ meta: { type: 'problem', docs: { description: getDescription(ruleName), recommended: 'recommended', url: 'https://github.com/foad/eslint-plugin-listeners', }, messages: { missingRemoveEventListener: '{{eventName}} on {{element}} does not have a corresponding removeEventListener', listenersDoNotMatch: '{{add}} and {{remove}} on {{element}} for {{eventName}} do not match', listenersDoNotMatchUseCapture: 'removeEventListener {{remove}} on {{element}} for {{eventName}} is missing useCapture parameter', prohibitedListener: 'event handler for {{eventName}} on {{element}} is {{type}}, {{type}}s are prohibited as event handlers', }, schema: [], }, create: (context: TSESLint.RuleContext<string, unknown[]>): TSESLint.RuleListener => { const listeners: Listeners = {}; return { 'CallExpression:exit': callExpressionListener(listeners), 'Program:exit': programListener(ruleName, listeners, context), }; }, defaultOptions: [], });