eslint-plugin-react-web-api
Version:
ESLint React's ESLint plugin for interacting with Web APIs
743 lines (732 loc) • 24.6 kB
JavaScript
'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;