UNPKG

eslint-plugin-solid

Version:
311 lines (296 loc) 11.4 kB
/** * FIXME: remove this comments and import when below issue is fixed. * This import is necessary for type generation due to a bug in the TypeScript compiler. * See: https://github.com/microsoft/TypeScript/issues/42873 */ // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { TSESLint } from "@typescript-eslint/utils"; import { TSESTree as T, ESLintUtils, ASTUtils } from "@typescript-eslint/utils"; import { isDOMElementName } from "../utils"; import { getScope, getSourceCode } from "../compat"; const createRule = ESLintUtils.RuleCreator.withoutDocs; const { getStaticValue } = ASTUtils; const COMMON_EVENTS = [ "onAnimationEnd", "onAnimationIteration", "onAnimationStart", "onBeforeInput", "onBlur", "onChange", "onClick", "onContextMenu", "onCopy", "onCut", "onDblClick", "onDrag", "onDragEnd", "onDragEnter", "onDragExit", "onDragLeave", "onDragOver", "onDragStart", "onDrop", "onError", "onFocus", "onFocusIn", "onFocusOut", "onGotPointerCapture", "onInput", "onInvalid", "onKeyDown", "onKeyPress", "onKeyUp", "onLoad", "onLostPointerCapture", "onMouseDown", "onMouseEnter", "onMouseLeave", "onMouseMove", "onMouseOut", "onMouseOver", "onMouseUp", "onPaste", "onPointerCancel", "onPointerDown", "onPointerEnter", "onPointerLeave", "onPointerMove", "onPointerOut", "onPointerOver", "onPointerUp", "onReset", "onScroll", "onSelect", "onSubmit", "onToggle", "onTouchCancel", "onTouchEnd", "onTouchMove", "onTouchStart", "onTransitionEnd", "onWheel", ] as const; type CommonEvent = (typeof COMMON_EVENTS)[number]; const COMMON_EVENTS_MAP = new Map<string, CommonEvent>( (function* () { for (const event of COMMON_EVENTS) { yield [event.toLowerCase(), event] as const; } })() ); const NONSTANDARD_EVENTS_MAP = { ondoubleclick: "onDblClick", }; const isCommonHandlerName = ( lowercaseHandlerName: string ): lowercaseHandlerName is Lowercase<CommonEvent> => COMMON_EVENTS_MAP.has(lowercaseHandlerName); const getCommonEventHandlerName = (lowercaseHandlerName: Lowercase<CommonEvent>): CommonEvent => COMMON_EVENTS_MAP.get(lowercaseHandlerName)!; const isNonstandardEventName = ( lowercaseEventName: string ): lowercaseEventName is keyof typeof NONSTANDARD_EVENTS_MAP => Boolean((NONSTANDARD_EVENTS_MAP as Record<string, string>)[lowercaseEventName]); const getStandardEventHandlerName = (lowercaseEventName: keyof typeof NONSTANDARD_EVENTS_MAP) => NONSTANDARD_EVENTS_MAP[lowercaseEventName]; type MessageIds = | "naming" | "capitalization" | "nonstandard" | "make-handler" | "make-attr" | "detected-attr" | "spread-handler"; type Options = [{ ignoreCase?: boolean; warnOnSpread?: boolean }?]; export default createRule<Options, MessageIds>({ meta: { type: "problem", docs: { description: "Enforce naming DOM element event handlers consistently and prevent Solid's analysis from misunderstanding whether a prop should be an event handler.", url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/event-handlers.md", }, fixable: "code", hasSuggestions: true, schema: [ { type: "object", properties: { ignoreCase: { type: "boolean", description: "if true, don't warn on ambiguously named event handlers like `onclick` or `onchange`", default: false, }, warnOnSpread: { type: "boolean", description: "if true, warn when spreading event handlers onto JSX. Enable for Solid < v1.6.", default: false, }, }, additionalProperties: false, }, ], messages: { "detected-attr": 'The {{name}} prop is named as an event handler (starts with "on"), but Solid knows its value ({{staticValue}}) is a string or number, so it will be treated as an attribute. If this is intentional, name this prop attr:{{name}}.', naming: "The {{name}} prop is ambiguous. If it is an event handler, change it to {{handlerName}}. If it is an attribute, change it to {{attrName}}.", capitalization: "The {{name}} prop should be renamed to {{fixedName}} for readability.", nonstandard: "The {{name}} prop should be renamed to {{fixedName}}, because it's not a standard event handler.", "make-handler": "Change the {{name}} prop to {{handlerName}}.", "make-attr": "Change the {{name}} prop to {{attrName}}.", "spread-handler": "The {{name}} prop should be added as a JSX attribute, not spread in. Solid doesn't add listeners when spreading into JSX.", }, }, defaultOptions: [], create(context) { const sourceCode = getSourceCode(context); return { JSXAttribute(node) { const openingElement = node.parent as T.JSXOpeningElement; if ( openingElement.name.type !== "JSXIdentifier" || !isDOMElementName(openingElement.name.name) ) { return; // bail if this is not a DOM/SVG element or web component } if (node.name.type === "JSXNamespacedName") { return; // bail early on attr:, on:, oncapture:, etc. props } // string name of the name node const { name } = node.name; if (!/^on[a-zA-Z]/.test(name)) { return; // bail if Solid doesn't consider the prop name an event handler } let staticValue: ReturnType<typeof getStaticValue> = null; if ( node.value?.type === "JSXExpressionContainer" && node.value.expression.type !== "JSXEmptyExpression" && node.value.expression.type !== "ArrayExpression" && // array syntax prevents inlining (staticValue = getStaticValue(node.value.expression, getScope(context, node))) !== null && (typeof staticValue.value === "string" || typeof staticValue.value === "number") ) { // One of the first things Solid (actually babel-plugin-dom-expressions) does with an // attribute is determine if it can be inlined into a template string instead of // injected programmatically. It runs // `attribute.get("value").get("expression").evaluate().value` on attributes with // JSXExpressionContainers, and if the statically evaluated value is a string or number, // it inlines it. This runs even for attributes that follow the naming convention for // event handlers. By starting an attribute name with "on", the user has signalled that // they intend the attribute to be an event handler. If the attribute value would be // inlined, report that. // https://github.com/ryansolid/dom-expressions/blob/cb3be7558c731e2a442e9c7e07d25373c40cf2be/packages/babel-plugin-jsx-dom-expressions/src/dom/element.js#L347 context.report({ node, messageId: "detected-attr", data: { name, staticValue: staticValue.value, }, }); } else if (node.value === null || node.value?.type === "Literal") { // Check for same as above for literal values context.report({ node, messageId: "detected-attr", data: { name, staticValue: node.value !== null ? node.value.value : true, }, }); } else if (!context.options[0]?.ignoreCase) { const lowercaseHandlerName = name.toLowerCase(); if (isNonstandardEventName(lowercaseHandlerName)) { const fixedName = getStandardEventHandlerName(lowercaseHandlerName); context.report({ node: node.name, messageId: "nonstandard", data: { name, fixedName }, fix: (fixer) => fixer.replaceText(node.name, fixedName), }); } else if (isCommonHandlerName(lowercaseHandlerName)) { const fixedName = getCommonEventHandlerName(lowercaseHandlerName); if (fixedName !== name) { // For common DOM event names, we know the user intended the prop to be an event handler. // Fix it to have an uppercase third letter and be properly camel-cased. context.report({ node: node.name, messageId: "capitalization", data: { name, fixedName }, fix: (fixer) => fixer.replaceText(node.name, fixedName), }); } } else if (name[2] === name[2].toLowerCase()) { // this includes words like `only` and `ongoing` as well as unknown handlers like `onfoobar`. // Enforce using either /^on[A-Z]/ (event handler) or /^attr:on[a-z]/ (forced regular attribute) // to make user intent clear and code maximally readable const handlerName = `on${name[2].toUpperCase()}${name.slice(3)}`; const attrName = `attr:${name}`; context.report({ node: node.name, messageId: "naming", data: { name, attrName, handlerName }, suggest: [ { messageId: "make-handler", data: { name, handlerName }, fix: (fixer) => fixer.replaceText(node.name, handlerName), }, { messageId: "make-attr", data: { name, attrName }, fix: (fixer) => fixer.replaceText(node.name, attrName), }, ], }); } } }, Property(node: T.Property) { if ( context.options[0]?.warnOnSpread && node.parent?.type === "ObjectExpression" && node.parent.parent?.type === "JSXSpreadAttribute" && node.parent.parent.parent?.type === "JSXOpeningElement" ) { const openingElement = node.parent.parent.parent; if ( openingElement.name.type === "JSXIdentifier" && isDOMElementName(openingElement.name.name) ) { if (node.key.type === "Identifier" && /^on/.test(node.key.name)) { const handlerName = node.key.name; // An event handler is being spread in (ex. <button {...{ onClick }} />), which doesn't // actually add an event listener, just a plain attribute. context.report({ node, messageId: "spread-handler", data: { name: node.key.name, }, *fix(fixer) { const commaAfter = sourceCode.getTokenAfter(node); yield fixer.remove( (node.parent as T.ObjectExpression).properties.length === 1 ? node.parent!.parent! : node ); if (commaAfter?.value === ",") { yield fixer.remove(commaAfter); } yield fixer.insertTextAfter( node.parent!.parent!, ` ${handlerName}={${sourceCode.getText(node.value)}}` ); }, }); } } } }, }; }, });