UNPKG

eslint-plugin-react-web-api

Version:

ESLint React's ESLint plugin for interacting with Web APIs

743 lines (732 loc) • 24.6 kB
'use strict'; var shared = require('@eslint-react/shared'); var AST = require('@eslint-react/ast'); var ER4 = require('@eslint-react/core'); var eff = require('@eslint-react/eff'); var VAR = require('@eslint-react/var'); var utils = require('@typescript-eslint/utils'); var tsPattern = require('ts-pattern'); function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var AST__namespace = /*#__PURE__*/_interopNamespace(AST); var ER4__namespace = /*#__PURE__*/_interopNamespace(ER4); var VAR__namespace = /*#__PURE__*/_interopNamespace(VAR); var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name3 in all) __defProp(target, name3, { get: all[name3], enumerable: true }); }; // src/configs/recommended.ts var recommended_exports = {}; __export(recommended_exports, { name: () => name, rules: () => rules, settings: () => settings }); var name = "react-web-api/recommended"; var 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" }; var settings = { "react-x": shared.DEFAULT_ESLINT_REACT_SETTINGS }; // package.json var name2 = "eslint-plugin-react-web-api"; var version = "1.46.0"; var createRule = utils.ESLintUtils.RuleCreator(shared.getDocsUrl("web-api")); function getPhaseKindOfFunction(node) { return tsPattern.match(node).when(ER4__namespace.isFunctionOfUseEffectSetup, () => "setup").when(ER4__namespace.isFunctionOfUseEffectCleanup, () => "cleanup").when(ER4__namespace.isFunctionOfComponentDidMount, () => "mount").when(ER4__namespace.isFunctionOfComponentWillUnmount, () => "unmount").otherwise(() => null); } // src/rules/no-leaked-event-listener.ts var RULE_NAME = "no-leaked-event-listener"; var RULE_FEATURES = []; var defaultOptions = { capture: false, // once: false, signal: eff._ }; function getCallKind(node) { switch (true) { case (node.callee.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.P.union("addEventListener", "removeEventListener", "abort"))(node.callee.name)): return node.callee.name; case (node.callee.type === utils.AST_NODE_TYPES.MemberExpression && node.callee.property.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.P.union("addEventListener", "removeEventListener", "abort"))(node.callee.property.name)): return node.callee.property.name; default: return "other"; } } function getFunctionKind(node) { return getPhaseKindOfFunction(node) ?? "other"; } function getSignalValueExpression(node, initialScope) { if (node == null) return eff._; switch (node.type) { case utils.AST_NODE_TYPES.Identifier: { return getSignalValueExpression(VAR__namespace.getVariableInitNode(VAR__namespace.findVariable(node, initialScope), 0), initialScope); } case utils.AST_NODE_TYPES.MemberExpression: return node; default: return eff._; } } function getOptions(node, initialScope) { function findProp(properties, propName) { return VAR__namespace.findPropertyInProperties(propName, properties, initialScope); } function getPropValue(prop, filter = (a) => true) { if (prop?.type !== utils.AST_NODE_TYPES.Property) return eff._; const { value } = prop; let v = value; switch (value.type) { case utils.AST_NODE_TYPES.Literal: { v = value.value; break; } default: { v = VAR__namespace.toStaticValue({ kind: "lazy", node: value, initialScope }).value; break; } } return filter(v) ? v : eff._; } function getOpts(node2) { switch (node2.type) { case utils.AST_NODE_TYPES.Identifier: { const variable = VAR__namespace.findVariable(node2, initialScope); const variableNode = VAR__namespace.getVariableInitNode(variable, 0); if (variableNode?.type === utils.AST_NODE_TYPES.ObjectExpression) { return getOpts(variableNode); } return defaultOptions; } case utils.AST_NODE_TYPES.Literal: { return { ...defaultOptions, capture: Boolean(node2.value) }; } case utils.AST_NODE_TYPES.ObjectExpression: { const pCapture = findProp(node2.properties, "capture"); const vCapture = !!getPropValue(pCapture); const pSignal = findProp(node2.properties, "signal"); const vSignal = pSignal?.type === utils.AST_NODE_TYPES.Property ? getSignalValueExpression(pSignal.value, initialScope) : eff._; return { capture: vCapture, signal: vSignal }; } default: { return defaultOptions; } } } return getOpts(node); } var no_leaked_event_listener_default = createRule({ meta: { type: "problem", docs: { description: "Prevents leaked `addEventListener` in a component or custom Hook.", [Symbol.for("rule_features")]: RULE_FEATURES }, 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, create, defaultOptions: [] }); function create(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 === utils.AST_NODE_TYPES.MemberExpression && b.type === utils.AST_NODE_TYPES.MemberExpression): return AST__namespace.isNodeEqual(a.object, b.object); // TODO: Maybe there other cases to consider here. 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 (!ER4__namespace.isInversePhase(aPhase, rPhase)) { return false; } return isSameObject(aCallee, rCallee) && AST__namespace.isNodeEqual(aListener, rListener) && VAR__namespace.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__namespace.isFunction(listener)) { return; } if (options.signal != null) { return; } context.report({ messageId: "unexpectedInlineFunction", node: listener, data: { eventMethodKind: callKind } }); } return { [":function"](node) { const kind = getFunctionKind(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 (!ER4__namespace.ComponentPhaseRelevance.has(fKind)) { return; } tsPattern.match(getCallKind(node)).with("addEventListener", (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); aEntries.push({ ...opts, kind: "addEventListener", type, node, callee, listener, 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, kind: "removeEventListener", type, node, callee, listener, phase: fKind }); }).with("abort", () => { abortedSignals.push(node.callee); }).otherwise(() => null); }, ["Program:exit"]() { for (const aEntry of aEntries) { const signal = aEntry.signal; if (signal != null && abortedSignals.some((a) => isSameObject(a, signal))) { 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; } } } }; } var RULE_NAME2 = "no-leaked-interval"; var RULE_FEATURES2 = []; function getCallKind2(node) { switch (true) { case (node.callee.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.P.union("setInterval", "clearInterval"))(node.callee.name)): return node.callee.name; case (node.callee.type === utils.AST_NODE_TYPES.MemberExpression && node.callee.property.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.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: "Prevents leaked `setInterval` in a component or custom Hook.", [Symbol.for("rule_features")]: RULE_FEATURES2 }, 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_NAME2, create: create2, defaultOptions: [] }); function create2(context) { if (!context.sourceCode.text.includes("setInterval")) { return {}; } const fEntries = []; const sEntries = []; const cEntries = []; function isInverseEntry(a, b) { return ER4__namespace.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 (getCallKind2(node)) { case "setInterval": { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) { break; } if (!ER4__namespace.ComponentPhaseRelevance.has(fEntry.kind)) { break; } const intervalIdNode = VAR__namespace.getVariableDeclaratorId(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 (!ER4__namespace.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; } } } }; } var RULE_NAME3 = "no-leaked-resize-observer"; var RULE_FEATURES3 = []; function isNewResizeObserver(node) { return node?.type === utils.AST_NODE_TYPES.NewExpression && node.callee.type === utils.AST_NODE_TYPES.Identifier && node.callee.name === "ResizeObserver"; } function isFromObserver(context, node) { switch (true) { case node.type === utils.AST_NODE_TYPES.Identifier: { const initialScope = context.sourceCode.getScope(node); const object = VAR__namespace.getVariableInitNode(VAR__namespace.findVariable(node, initialScope), 0); return isNewResizeObserver(object); } case node.type === utils.AST_NODE_TYPES.MemberExpression: return isFromObserver(context, node.object); default: return false; } } function getCallKind3(context, node) { switch (true) { case (node.callee.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.P.union("observe", "unobserve", "disconnect"))(node.callee.name) && isFromObserver(context, node.callee)): return node.callee.name; case (node.callee.type === utils.AST_NODE_TYPES.MemberExpression && node.callee.property.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.P.union("observe", "unobserve", "disconnect"))(node.callee.property.name) && isFromObserver(context, node.callee)): return node.callee.property.name; default: return "other"; } } function getFunctionKind2(node) { return getPhaseKindOfFunction(node) ?? "other"; } var no_leaked_resize_observer_default = createRule({ meta: { type: "problem", docs: { description: "Prevents leaked `ResizeObserver` in a component or custom Hook.", [Symbol.for("rule_features")]: RULE_FEATURES3 }, 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_NAME3, create: create3, defaultOptions: [] }); function create3(context) { if (!context.sourceCode.text.includes("ResizeObserver")) { return {}; } const fEntries = []; const observers = []; const oEntries = []; const uEntries = []; const dEntries = []; return { [":function"](node) { const kind = getFunctionKind2(node); fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { if (node.callee.type !== utils.AST_NODE_TYPES.MemberExpression) { return; } const fKind = fEntries.findLast((x) => x.kind !== "other")?.kind; if (fKind == null || !ER4__namespace.ComponentPhaseRelevance.has(fKind)) { return; } const { object } = node.callee; tsPattern.match(getCallKind3(context, node)).with("disconnect", () => { dEntries.push({ kind: "disconnect", node, callee: node.callee, observer: object, observerKind: "ResizeObserver", phase: fKind }); }).with("observe", () => { const [element] = node.arguments; if (element == null) { return; } oEntries.push({ kind: "observe", node, callee: node.callee, element, observer: object, observerKind: "ResizeObserver", phase: fKind }); }).with("unobserve", () => { const [element] = node.arguments; if (element == null) { return; } uEntries.push({ kind: "unobserve", node, callee: node.callee, element, observer: object, observerKind: "ResizeObserver", phase: fKind }); }).otherwise(() => null); }, ["NewExpression"](node) { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) return; if (!ER4__namespace.ComponentPhaseRelevance.has(fEntry.kind)) { return; } if (!isNewResizeObserver(node)) { return; } const id = ER4__namespace.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) => ER4__namespace.isInstanceIdEqual(context, e.observer, id))) { continue; } const oentries = oEntries.filter((e) => ER4__namespace.isInstanceIdEqual(context, e.observer, id)); const uentries = uEntries.filter((e) => ER4__namespace.isInstanceIdEqual(context, e.observer, id)); const isDynamic = (node2) => node2?.type === utils.AST_NODE_TYPES.CallExpression || AST__namespace.isConditional(node2); const isPhaseNode = (node2) => node2 === phaseNode; const hasDynamicallyAdded = oentries.some((e) => !isPhaseNode(AST__namespace.findParentNode(e.node, eff.or(isDynamic, isPhaseNode)))); if (hasDynamicallyAdded) { context.report({ messageId: "expectedDisconnectInControlFlow", node }); continue; } for (const oEntry of oentries) { if (uentries.some((uEntry) => ER4__namespace.isInstanceIdEqual(context, uEntry.element, oEntry.element))) { continue; } context.report({ messageId: "expectedDisconnectOrUnobserveInCleanup", node: oEntry.node }); } } } }; } var RULE_NAME4 = "no-leaked-timeout"; var RULE_FEATURES4 = []; function getCallKind4(node) { switch (true) { case (node.callee.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.P.union("setTimeout", "clearTimeout"))(node.callee.name)): return node.callee.name; case (node.callee.type === utils.AST_NODE_TYPES.MemberExpression && node.callee.property.type === utils.AST_NODE_TYPES.Identifier && tsPattern.isMatching(tsPattern.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: "Prevents leaked `setTimeout` in a component or custom Hook.", [Symbol.for("rule_features")]: RULE_FEATURES4 }, 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_NAME4, create: create4, defaultOptions: [] }); function create4(context) { if (!context.sourceCode.text.includes("setTimeout")) { return {}; } const fEntries = []; const sEntries = []; const rEntries = []; function isInverseEntry(a, b) { return ER4__namespace.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 (!ER4__namespace.ComponentPhaseRelevance.has(fEntry?.kind)) { return; } switch (getCallKind4(node)) { case "setTimeout": { const timeoutIdNode = VAR__namespace.getVariableDeclaratorId(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; } } } }; } // src/plugin.ts var plugin = { meta: { name: name2, 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 } }; // src/index.ts function makeConfig(config) { return { ...config, plugins: { "react-web-api": plugin } }; } function makeLegacyConfig({ rules: rules2 }) { return { plugins: ["react-web-api"], rules: rules2 }; } var index_default = { ...plugin, configs: { ["recommended"]: makeConfig(recommended_exports), ["recommended-legacy"]: makeLegacyConfig(recommended_exports) } }; module.exports = index_default;