UNPKG

eslint-plugin-react-web-api

Version:

ESLint React's ESLint plugin for interacting with Web APIs

623 lines (612 loc) • 20.7 kB
import { DEFAULT_ESLINT_REACT_SETTINGS, WEBSITE_URL, getConfigAdapters } from "@eslint-react/shared"; import * as AST from "@eslint-react/ast"; import { ComponentPhaseRelevance, getInstanceId, getPhaseKindOfFunction, isInitializedFromReactNative, isInstanceIdEqual, isInversePhase } from "@eslint-react/core"; import { or, unit } from "@eslint-react/eff"; import { findAssignmentTarget, findProperty, findVariable, getVariableDefinitionNode, isNodeValueEqual } from "@eslint-react/var"; import { AST_NODE_TYPES, ESLintUtils } from "@typescript-eslint/utils"; import { getStaticValue } from "@typescript-eslint/utils/ast-utils"; import { P, isMatching, match } from "ts-pattern"; //#region rolldown:runtime var __defProp = Object.defineProperty; var __exportAll = (all, symbols) => { let target = {}; for (var name$2 in all) { __defProp(target, name$2, { get: all[name$2], enumerable: true }); } if (symbols) { __defProp(target, Symbol.toStringTag, { value: "Module" }); } return target; }; //#endregion //#region src/configs/recommended.ts var recommended_exports = /* @__PURE__ */ __exportAll({ name: () => name$1, rules: () => rules, settings: () => settings }); const name$1 = "react-web-api/recommended"; const rules = { "react-web-api/no-leaked-event-listener": "warn", "react-web-api/no-leaked-interval": "warn", "react-web-api/no-leaked-resize-observer": "warn", "react-web-api/no-leaked-timeout": "warn" }; const settings = { "react-x": DEFAULT_ESLINT_REACT_SETTINGS }; //#endregion //#region package.json var name = "eslint-plugin-react-web-api"; var version = "2.5.0"; //#endregion //#region src/utils/create-rule.ts function getDocsUrl(ruleName) { return `${WEBSITE_URL}/docs/rules/web-api-${ruleName}`; } const createRule = ESLintUtils.RuleCreator(getDocsUrl); //#endregion //#region src/rules/no-leaked-event-listener.ts const RULE_NAME$3 = "no-leaked-event-listener"; const defaultOptions = { capture: false, signal: unit }; function getCallKind$3(node) { switch (true) { case node.callee.type === AST_NODE_TYPES.Identifier && isMatching(P.union("addEventListener", "removeEventListener", "abort"))(node.callee.name): return node.callee.name; case node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier && isMatching(P.union("addEventListener", "removeEventListener", "abort"))(node.callee.property.name): return node.callee.property.name; default: return "other"; } } function getFunctionKind$1(node) { return getPhaseKindOfFunction(node) ?? "other"; } function getSignalValueExpression(node, initialScope) { if (node == null) return unit; switch (node.type) { case AST_NODE_TYPES.Identifier: return getSignalValueExpression(getVariableDefinitionNode(findVariable(node, initialScope), 0), initialScope); case AST_NODE_TYPES.MemberExpression: return node; default: return unit; } } function getOptions(node, initialScope) { function findProp(properties, propName) { return findProperty(propName, properties, initialScope); } function getPropValue(prop, filter = (a) => true) { if (prop?.type !== AST_NODE_TYPES.Property) return unit; const { value } = prop; let v = value; switch (value.type) { case AST_NODE_TYPES.Literal: v = value.value; break; default: v = getStaticValue(value, initialScope)?.value; break; } return filter(v) ? v : unit; } function getOpts(node$1) { switch (node$1.type) { case AST_NODE_TYPES.Identifier: { const variableNode = getVariableDefinitionNode(findVariable(node$1, initialScope), 0); if (variableNode?.type === AST_NODE_TYPES.ObjectExpression) return getOpts(variableNode); return defaultOptions; } case AST_NODE_TYPES.Literal: return { ...defaultOptions, capture: Boolean(node$1.value) }; case AST_NODE_TYPES.ObjectExpression: { const vCapture = !!getPropValue(findProp(node$1.properties, "capture")); const pSignal = findProp(node$1.properties, "signal"); return { capture: vCapture, signal: pSignal?.type === AST_NODE_TYPES.Property ? getSignalValueExpression(pSignal.value, initialScope) : unit }; } default: return defaultOptions; } } return getOpts(node); } var no_leaked_event_listener_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'addEventListener' in a component or custom hook has a corresponding 'removeEventListener'." }, messages: { expectedRemoveEventListenerInCleanup: "An 'addEventListener' in '{{effectMethodKind}}' should have a corresponding 'removeEventListener' in its cleanup function.", expectedRemoveEventListenerInUnmount: "An 'addEventListener' in 'componentDidMount' should have a corresponding 'removeEventListener' in 'componentWillUnmount' method.", unexpectedInlineFunction: "A/an '{{eventMethodKind}}' should not have an inline listener function." }, schema: [] }, name: RULE_NAME$3, create: create$3, defaultOptions: [] }); function create$3(context) { if (!context.sourceCode.text.includes("addEventListener")) return {}; if (!/use\w*Effect|componentDidMount|componentWillUnmount/u.test(context.sourceCode.text)) return {}; const fEntries = []; const aEntries = []; const rEntries = []; const abortedSignals = []; function isSameObject(a, b) { switch (true) { case a.type === AST_NODE_TYPES.MemberExpression && b.type === AST_NODE_TYPES.MemberExpression: return AST.isNodeEqual(a.object, b.object); default: return false; } } function isInverseEntry(aEntry, rEntry) { const { type: aType, callee: aCallee, capture: aCapture, listener: aListener, phase: aPhase } = aEntry; const { type: rType, callee: rCallee, capture: rCapture, listener: rListener, phase: rPhase } = rEntry; if (!isInversePhase(aPhase, rPhase)) return false; return isSameObject(aCallee, rCallee) && AST.isNodeEqual(aListener, rListener) && isNodeValueEqual(aType, rType, [context.sourceCode.getScope(aType), context.sourceCode.getScope(rType)]) && aCapture === rCapture; } function checkInlineFunction(node, callKind, options) { const listener = node.arguments.at(1); if (!AST.isFunction(listener)) return; if (options.signal != null) return; context.report({ messageId: "unexpectedInlineFunction", node: listener, data: { eventMethodKind: callKind } }); } return { [":function"](node) { const kind = getFunctionKind$1(node); fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { const fKind = fEntries.findLast((x) => x.kind !== "other")?.kind; if (fKind == null) return; if (!ComponentPhaseRelevance.has(fKind)) return; match(getCallKind$3(node)).with("addEventListener", (callKind) => { if (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.object.type === AST_NODE_TYPES.Identifier && isInitializedFromReactNative(node.callee.object.name, context.sourceCode.getScope(node))) return; const [type, listener, options] = node.arguments; if (type == null || listener == null) return; const opts = options == null ? defaultOptions : getOptions(options, context.sourceCode.getScope(options)); const { callee } = node; checkInlineFunction(node, callKind, opts); aEntries.push({ ...opts, type, node, callee, listener, method: "addEventListener", phase: fKind }); }).with("removeEventListener", (callKind) => { const [type, listener, options] = node.arguments; if (type == null || listener == null) return; const opts = options == null ? defaultOptions : getOptions(options, context.sourceCode.getScope(options)); const { callee } = node; checkInlineFunction(node, callKind, opts); rEntries.push({ ...opts, type, node, callee, listener, method: "removeEventListener", phase: fKind }); }).with("abort", () => { abortedSignals.push(node.callee); }).otherwise(() => null); }, ["Program:exit"]() { for (const aEntry of aEntries) { if (aEntry.signal != null) continue; if (rEntries.some((rEntry) => isInverseEntry(aEntry, rEntry))) continue; switch (aEntry.phase) { case "setup": case "cleanup": context.report({ messageId: "expectedRemoveEventListenerInCleanup", node: aEntry.node, data: { effectMethodKind: "useEffect" } }); continue; case "mount": case "unmount": context.report({ messageId: "expectedRemoveEventListenerInUnmount", node: aEntry.node }); continue; } } } }; } //#endregion //#region src/rules/no-leaked-interval.ts const RULE_NAME$2 = "no-leaked-interval"; function getCallKind$2(node) { switch (true) { case node.callee.type === AST_NODE_TYPES.Identifier && isMatching(P.union("setInterval", "clearInterval"))(node.callee.name): return node.callee.name; case node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier && isMatching(P.union("setInterval", "clearInterval"))(node.callee.property.name): return node.callee.property.name; default: return "other"; } } var no_leaked_interval_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'setInterval' in a component or custom hook has a corresponding 'clearInterval'." }, messages: { expectedClearIntervalInCleanup: "A 'setInterval' created in '{{ kind }}' must be cleared with 'clearInterval' in the cleanup function.", expectedClearIntervalInUnmount: "A 'setInterval' created in '{{ kind }}' must be cleared with 'clearInterval' in the 'componentWillUnmount' method.", expectedIntervalId: "A 'setInterval' must be assigned to a variable for proper cleanup." }, schema: [] }, name: RULE_NAME$2, create: create$2, defaultOptions: [] }); function create$2(context) { if (!context.sourceCode.text.includes("setInterval")) return {}; const fEntries = []; const sEntries = []; const cEntries = []; function isInverseEntry(a, b) { return isInstanceIdEqual(context, a.timerId, b.timerId); } return { [":function"](node) { const kind = getPhaseKindOfFunction(node) ?? "other"; fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { switch (getCallKind$2(node)) { case "setInterval": { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) break; if (!ComponentPhaseRelevance.has(fEntry.kind)) break; const intervalIdNode = findAssignmentTarget(node); if (intervalIdNode == null) { context.report({ messageId: "expectedIntervalId", node }); break; } sEntries.push({ kind: "interval", node, callee: node.callee, phase: fEntry.kind, timerId: intervalIdNode }); break; } case "clearInterval": { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) break; if (!ComponentPhaseRelevance.has(fEntry.kind)) break; const [intervalIdNode] = node.arguments; if (intervalIdNode == null) break; cEntries.push({ kind: "interval", node, callee: node.callee, phase: fEntry.kind, timerId: intervalIdNode }); break; } } }, ["Program:exit"]() { for (const sEntry of sEntries) { if (cEntries.some((cEntry) => isInverseEntry(sEntry, cEntry))) continue; switch (sEntry.phase) { case "setup": case "cleanup": context.report({ messageId: "expectedClearIntervalInCleanup", node: sEntry.node, data: { kind: "useEffect" } }); continue; case "mount": case "unmount": context.report({ messageId: "expectedClearIntervalInUnmount", node: sEntry.node, data: { kind: "componentDidMount" } }); continue; } } } }; } //#endregion //#region src/rules/no-leaked-resize-observer.ts const RULE_NAME$1 = "no-leaked-resize-observer"; function isNewResizeObserver(node) { return node?.type === AST_NODE_TYPES.NewExpression && node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === "ResizeObserver"; } function isFromObserver(context, node) { switch (true) { case node.type === AST_NODE_TYPES.Identifier: return isNewResizeObserver(getVariableDefinitionNode(findVariable(node, context.sourceCode.getScope(node)), 0)); case node.type === AST_NODE_TYPES.MemberExpression: return isFromObserver(context, node.object); default: return false; } } function getCallKind$1(context, node) { switch (true) { case node.callee.type === AST_NODE_TYPES.Identifier && isMatching(P.union("observe", "unobserve", "disconnect"))(node.callee.name) && isFromObserver(context, node.callee): return node.callee.name; case node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier && isMatching(P.union("observe", "unobserve", "disconnect"))(node.callee.property.name) && isFromObserver(context, node.callee): return node.callee.property.name; default: return "other"; } } function getFunctionKind(node) { return getPhaseKindOfFunction(node) ?? "other"; } var no_leaked_resize_observer_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'ResizeObserver' created in a component or custom hook has a corresponding 'ResizeObserver.disconnect()'." }, messages: { expectedDisconnectInControlFlow: "Dynamically added 'ResizeObserver.observe' should be cleared all at once using 'ResizeObserver.disconnect' in the cleanup function.", expectedDisconnectOrUnobserveInCleanup: "A 'ResizeObserver' instance created in 'useEffect' must be disconnected in the cleanup function.", unexpectedFloatingInstance: "A 'ResizeObserver' instance created in component or custom hook must be assigned to a variable for proper cleanup." }, schema: [] }, name: RULE_NAME$1, create: create$1, defaultOptions: [] }); function create$1(context) { if (!context.sourceCode.text.includes("ResizeObserver")) return {}; const fEntries = []; const observers = []; const oEntries = []; const uEntries = []; const dEntries = []; return { [":function"](node) { const kind = getFunctionKind(node); fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return; const fKind = fEntries.findLast((x) => x.kind !== "other")?.kind; if (fKind == null || !ComponentPhaseRelevance.has(fKind)) return; const { object } = node.callee; match(getCallKind$1(context, node)).with("disconnect", () => { dEntries.push({ kind: "ResizeObserver", node, callee: node.callee, method: "disconnect", observer: object, phase: fKind }); }).with("observe", () => { const [element] = node.arguments; if (element == null) return; oEntries.push({ kind: "ResizeObserver", node, callee: node.callee, element, method: "observe", observer: object, phase: fKind }); }).with("unobserve", () => { const [element] = node.arguments; if (element == null) return; uEntries.push({ kind: "ResizeObserver", node, callee: node.callee, element, method: "unobserve", observer: object, phase: fKind }); }).otherwise(() => null); }, ["NewExpression"](node) { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) return; if (!ComponentPhaseRelevance.has(fEntry.kind)) return; if (!isNewResizeObserver(node)) return; const id = getInstanceId(node); if (id == null) { context.report({ messageId: "unexpectedFloatingInstance", node }); return; } observers.push({ id, node, phase: fEntry.kind, phaseNode: fEntry.node }); }, ["Program:exit"]() { for (const { id, node, phaseNode } of observers) { if (dEntries.some((e) => isInstanceIdEqual(context, e.observer, id))) continue; const oentries = oEntries.filter((e) => isInstanceIdEqual(context, e.observer, id)); const uentries = uEntries.filter((e) => isInstanceIdEqual(context, e.observer, id)); const isDynamic = (node$1) => node$1?.type === AST_NODE_TYPES.CallExpression || AST.isConditional(node$1); const isPhaseNode = (node$1) => node$1 === phaseNode; if (oentries.some((e) => !isPhaseNode(AST.findParentNode(e.node, or(isDynamic, isPhaseNode))))) { context.report({ messageId: "expectedDisconnectInControlFlow", node }); continue; } for (const oEntry of oentries) { if (uentries.some((uEntry) => isInstanceIdEqual(context, uEntry.element, oEntry.element))) continue; context.report({ messageId: "expectedDisconnectOrUnobserveInCleanup", node: oEntry.node }); } } } }; } //#endregion //#region src/rules/no-leaked-timeout.ts const RULE_NAME = "no-leaked-timeout"; function getCallKind(node) { switch (true) { case node.callee.type === AST_NODE_TYPES.Identifier && isMatching(P.union("setTimeout", "clearTimeout"))(node.callee.name): return node.callee.name; case node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier && isMatching(P.union("setTimeout", "clearTimeout"))(node.callee.property.name): return node.callee.property.name; default: return "other"; } } var no_leaked_timeout_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'setTimeout' in a component or custom hook has a corresponding 'clearTimeout'." }, messages: { expectedClearTimeoutInCleanup: "A 'setTimeout' created in '{{ kind }}' must be cleared with 'clearTimeout' in the cleanup function.", expectedClearTimeoutInUnmount: "A 'setTimeout' created in '{{ kind }}' must be cleared with 'clearTimeout' in the 'componentWillUnmount' method.", expectedTimeoutId: "A 'setTimeout' must be assigned to a variable for proper cleanup." }, schema: [] }, name: RULE_NAME, create, defaultOptions: [] }); function create(context) { if (!context.sourceCode.text.includes("setTimeout")) return {}; const fEntries = []; const sEntries = []; const rEntries = []; function isInverseEntry(a, b) { return isInstanceIdEqual(context, a.timerId, b.timerId); } return { [":function"](node) { const kind = getPhaseKindOfFunction(node) ?? "other"; fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { const fEntry = fEntries.findLast((f) => f.kind !== "other"); if (!ComponentPhaseRelevance.has(fEntry?.kind)) return; switch (getCallKind(node)) { case "setTimeout": { const timeoutIdNode = findAssignmentTarget(node); if (timeoutIdNode == null) { context.report({ messageId: "expectedTimeoutId", node }); break; } sEntries.push({ kind: "timeout", node, callee: node.callee, phase: fEntry.kind, timerId: timeoutIdNode }); break; } case "clearTimeout": { const [timeoutIdNode] = node.arguments; if (timeoutIdNode == null) break; rEntries.push({ kind: "timeout", node, callee: node.callee, phase: fEntry.kind, timerId: timeoutIdNode }); break; } } }, ["Program:exit"]() { for (const sEntry of sEntries) { if (rEntries.some((rEntry) => isInverseEntry(sEntry, rEntry))) continue; switch (sEntry.phase) { case "setup": case "cleanup": context.report({ messageId: "expectedClearTimeoutInCleanup", node: sEntry.node, data: { kind: "useEffect" } }); continue; case "mount": case "unmount": context.report({ messageId: "expectedClearTimeoutInUnmount", node: sEntry.node, data: { kind: "componentDidMount" } }); continue; } } } }; } //#endregion //#region src/plugin.ts const plugin = { meta: { name, version }, rules: { "no-leaked-event-listener": no_leaked_event_listener_default, "no-leaked-interval": no_leaked_interval_default, "no-leaked-resize-observer": no_leaked_resize_observer_default, "no-leaked-timeout": no_leaked_timeout_default } }; //#endregion //#region src/index.ts const { toFlatConfig } = getConfigAdapters("react-web-api", plugin); var src_default = { ...plugin, configs: { ["recommended"]: toFlatConfig(recommended_exports) } }; //#endregion export { src_default as default };