eslint-plugin-listeners
Version:
ESLint plugin for preventing memory leaks around event listeners
250 lines (225 loc) • 7.87 kB
text/typescript
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: [],
});