@prairielearn/eslint-plugin 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/rules/no-current-target-in-callback.d.ts +20 -0
- package/dist/rules/no-current-target-in-callback.d.ts.map +1 -0
- package/dist/rules/no-current-target-in-callback.js +140 -0
- package/dist/rules/no-current-target-in-callback.js.map +1 -0
- package/dist/tests/no-current-target-in-callback.test.d.ts +2 -0
- package/dist/tests/no-current-target-in-callback.test.d.ts.map +1 -0
- package/dist/tests/no-current-target-in-callback.test.js +109 -0
- package/dist/tests/no-current-target-in-callback.test.js.map +1 -0
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/rules/no-current-target-in-callback.ts +177 -0
- package/src/tests/no-current-target-in-callback.test.ts +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @prairielearn/eslint-plugin
|
|
2
2
|
|
|
3
|
+
## 3.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 174fbb4: Add `no-current-target-in-callback` lint rule to detect when `event.currentTarget` is accessed inside a nested callback within a React event handler. This pattern is problematic because React may execute callbacks asynchronously, at which point `currentTarget` may already be nullified.
|
|
8
|
+
|
|
3
9
|
## 3.0.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const rules: {
|
|
|
2
2
|
'aws-client-mandatory-config': import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingConfig", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
|
|
3
3
|
'aws-client-shared-config': import("@typescript-eslint/utils/ts-eslint").RuleModule<"improperConfig" | "unknownConfig", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
|
|
4
4
|
'jsx-no-dollar-interpolation': import("@typescript-eslint/utils/ts-eslint").RuleModule<"dollarInterpolationNotAllowed", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
|
|
5
|
+
'no-current-target-in-callback': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noCurrentTargetInCallback", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
|
|
5
6
|
'no-unused-sql-blocks': import("@typescript-eslint/utils/ts-eslint").RuleModule<"unusedSqlBlock", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
|
|
6
7
|
'safe-db-types': import("@typescript-eslint/utils/ts-eslint").RuleModule<"spreadAttributes" | "unsafeTypes", [({
|
|
7
8
|
allowDbTypes?: (string | RegExp)[] | undefined;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,KAAK;;;;;;;;;CAOjB,CAAC","sourcesContent":["import awsClientMandatoryConfig from './rules/aws-client-mandatory-config.js';\nimport awsClientSharedConfig from './rules/aws-client-shared-config.js';\nimport jsxNoDollarInterpolation from './rules/jsx-no-dollar-interpolation.js';\nimport noCurrentTargetInCallback from './rules/no-current-target-in-callback.js';\nimport noUnusedSqlBlocks from './rules/no-unused-sql-blocks.js';\nimport safeDbTypes from './rules/safe-db-types.js';\n\nexport const rules = {\n 'aws-client-mandatory-config': awsClientMandatoryConfig,\n 'aws-client-shared-config': awsClientSharedConfig,\n 'jsx-no-dollar-interpolation': jsxNoDollarInterpolation,\n 'no-current-target-in-callback': noCurrentTargetInCallback,\n 'no-unused-sql-blocks': noUnusedSqlBlocks,\n 'safe-db-types': safeDbTypes,\n};\n"]}
|
package/dist/index.js
CHANGED
|
@@ -7,12 +7,14 @@ exports.rules = void 0;
|
|
|
7
7
|
const aws_client_mandatory_config_js_1 = __importDefault(require("./rules/aws-client-mandatory-config.js"));
|
|
8
8
|
const aws_client_shared_config_js_1 = __importDefault(require("./rules/aws-client-shared-config.js"));
|
|
9
9
|
const jsx_no_dollar_interpolation_js_1 = __importDefault(require("./rules/jsx-no-dollar-interpolation.js"));
|
|
10
|
+
const no_current_target_in_callback_js_1 = __importDefault(require("./rules/no-current-target-in-callback.js"));
|
|
10
11
|
const no_unused_sql_blocks_js_1 = __importDefault(require("./rules/no-unused-sql-blocks.js"));
|
|
11
12
|
const safe_db_types_js_1 = __importDefault(require("./rules/safe-db-types.js"));
|
|
12
13
|
exports.rules = {
|
|
13
14
|
'aws-client-mandatory-config': aws_client_mandatory_config_js_1.default,
|
|
14
15
|
'aws-client-shared-config': aws_client_shared_config_js_1.default,
|
|
15
16
|
'jsx-no-dollar-interpolation': jsx_no_dollar_interpolation_js_1.default,
|
|
17
|
+
'no-current-target-in-callback': no_current_target_in_callback_js_1.default,
|
|
16
18
|
'no-unused-sql-blocks': no_unused_sql_blocks_js_1.default,
|
|
17
19
|
'safe-db-types': safe_db_types_js_1.default,
|
|
18
20
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAAA,4GAA8E;AAC9E,sGAAwE;AACxE,4GAA8E;AAC9E,8FAAgE;AAChE,gFAAmD;AAEtC,QAAA,KAAK,GAAG;IACnB,6BAA6B,EAAE,wCAAwB;IACvD,0BAA0B,EAAE,qCAAqB;IACjD,6BAA6B,EAAE,wCAAwB;IACvD,sBAAsB,EAAE,iCAAiB;IACzC,eAAe,EAAE,0BAAW;CAC7B,CAAC","sourcesContent":["import awsClientMandatoryConfig from './rules/aws-client-mandatory-config.js';\nimport awsClientSharedConfig from './rules/aws-client-shared-config.js';\nimport jsxNoDollarInterpolation from './rules/jsx-no-dollar-interpolation.js';\nimport noUnusedSqlBlocks from './rules/no-unused-sql-blocks.js';\nimport safeDbTypes from './rules/safe-db-types.js';\n\nexport const rules = {\n 'aws-client-mandatory-config': awsClientMandatoryConfig,\n 'aws-client-shared-config': awsClientSharedConfig,\n 'jsx-no-dollar-interpolation': jsxNoDollarInterpolation,\n 'no-unused-sql-blocks': noUnusedSqlBlocks,\n 'safe-db-types': safeDbTypes,\n};\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAAA,4GAA8E;AAC9E,sGAAwE;AACxE,4GAA8E;AAC9E,gHAAiF;AACjF,8FAAgE;AAChE,gFAAmD;AAEtC,QAAA,KAAK,GAAG;IACnB,6BAA6B,EAAE,wCAAwB;IACvD,0BAA0B,EAAE,qCAAqB;IACjD,6BAA6B,EAAE,wCAAwB;IACvD,+BAA+B,EAAE,0CAAyB;IAC1D,sBAAsB,EAAE,iCAAiB;IACzC,eAAe,EAAE,0BAAW;CAC7B,CAAC","sourcesContent":["import awsClientMandatoryConfig from './rules/aws-client-mandatory-config.js';\nimport awsClientSharedConfig from './rules/aws-client-shared-config.js';\nimport jsxNoDollarInterpolation from './rules/jsx-no-dollar-interpolation.js';\nimport noCurrentTargetInCallback from './rules/no-current-target-in-callback.js';\nimport noUnusedSqlBlocks from './rules/no-unused-sql-blocks.js';\nimport safeDbTypes from './rules/safe-db-types.js';\n\nexport const rules = {\n 'aws-client-mandatory-config': awsClientMandatoryConfig,\n 'aws-client-shared-config': awsClientSharedConfig,\n 'jsx-no-dollar-interpolation': jsxNoDollarInterpolation,\n 'no-current-target-in-callback': noCurrentTargetInCallback,\n 'no-unused-sql-blocks': noUnusedSqlBlocks,\n 'safe-db-types': safeDbTypes,\n};\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* This rule detects when `event.currentTarget` is accessed inside a nested
|
|
4
|
+
* callback function within a React event handler. This is problematic because
|
|
5
|
+
* React may execute callbacks (like those passed to setState) asynchronously,
|
|
6
|
+
* at which point `currentTarget` may already be nullified.
|
|
7
|
+
*
|
|
8
|
+
* Bad:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))}
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Good:
|
|
14
|
+
* ```tsx
|
|
15
|
+
* onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))}
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
declare const _default: ESLintUtils.RuleModule<"noCurrentTargetInCallback", [], unknown, ESLintUtils.RuleListener>;
|
|
19
|
+
export default _default;
|
|
20
|
+
//# sourceMappingURL=no-current-target-in-callback.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-current-target-in-callback.d.ts","sourceRoot":"","sources":["../../src/rules/no-current-target-in-callback.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAiB,MAAM,0BAA0B,CAAC;AAEtE;;;;;;;;;;;;;;;GAeG;;AACH,wBA8JG","sourcesContent":["import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';\n\n/**\n * This rule detects when `event.currentTarget` is accessed inside a nested\n * callback function within a React event handler. This is problematic because\n * React may execute callbacks (like those passed to setState) asynchronously,\n * at which point `currentTarget` may already be nullified.\n *\n * Bad:\n * ```tsx\n * onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))}\n * ```\n *\n * Good:\n * ```tsx\n * onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))}\n * ```\n */\nexport default ESLintUtils.RuleCreator.withoutDocs({\n meta: {\n type: 'problem',\n messages: {\n noCurrentTargetInCallback:\n 'Accessing event.currentTarget inside a callback may fail because currentTarget can be nullified before the callback runs. Destructure currentTarget at the start of the event handler instead.',\n },\n schema: [],\n },\n defaultOptions: [],\n\n create(context) {\n // Track event handler parameters and their scopes\n const eventHandlerParams = new Map<TSESTree.Node, TSESTree.Identifier>();\n\n /** Check if a node is inside a nested function relative to the event handler */\n function isInsideNestedFunction(\n node: TSESTree.Node,\n eventHandlerFunction: TSESTree.Node,\n ): boolean {\n let current: TSESTree.Node | undefined = node.parent;\n let foundNestedFunction = false;\n\n while (current && current !== eventHandlerFunction) {\n if (\n current.type === 'ArrowFunctionExpression' ||\n current.type === 'FunctionExpression' ||\n current.type === 'FunctionDeclaration'\n ) {\n foundNestedFunction = true;\n }\n current = current.parent;\n }\n\n return foundNestedFunction && current === eventHandlerFunction;\n }\n\n /** Find the function that contains this node */\n function findContainingFunction(\n node: TSESTree.Node,\n ): TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | null {\n let current: TSESTree.Node | undefined = node.parent;\n\n while (current) {\n if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {\n return current;\n }\n current = current.parent;\n }\n\n return null;\n }\n\n /** Check if a function is a JSX event handler */\n function isJSXEventHandler(\n func: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,\n ): boolean {\n const parent = func.parent;\n\n // Check for JSX attribute like onChange={...}\n if (parent?.type === 'JSXExpressionContainer') {\n const jsxAttribute = parent.parent;\n if (jsxAttribute?.type === 'JSXAttribute') {\n const attrName = jsxAttribute.name.type === 'JSXIdentifier' ? jsxAttribute.name.name : '';\n // Match common event handler patterns\n return /^on[A-Z]/.test(attrName);\n }\n }\n\n return false;\n }\n\n /** Get the first parameter of an event handler if it looks like an event */\n function getEventParameter(\n func: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,\n ): TSESTree.Identifier | null {\n const firstParam = func.params[0];\n\n if (!firstParam) return null;\n\n // Simple identifier parameter like (e) => ...\n if (firstParam.type === 'Identifier') {\n return firstParam;\n }\n\n return null;\n }\n\n return {\n // When we enter a JSX event handler, track its event parameter\n 'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression': (\n node: TSESTree.ArrowFunctionExpression,\n ) => {\n if (isJSXEventHandler(node)) {\n const eventParam = getEventParameter(node);\n if (eventParam) {\n eventHandlerParams.set(node, eventParam);\n }\n }\n },\n 'JSXAttribute > JSXExpressionContainer > FunctionExpression': (\n node: TSESTree.FunctionExpression,\n ) => {\n if (isJSXEventHandler(node)) {\n const eventParam = getEventParameter(node);\n if (eventParam) {\n eventHandlerParams.set(node, eventParam);\n }\n }\n },\n\n // Check for .currentTarget access\n MemberExpression(node) {\n // Only check for .currentTarget property access\n if (node.property.type !== 'Identifier' || node.property.name !== 'currentTarget') {\n return;\n }\n\n // Check if the object is an identifier (like `e` in `e.currentTarget`)\n if (node.object.type !== 'Identifier') {\n return;\n }\n\n const objectName = node.object.name;\n\n // Find the containing function\n const containingFunction = findContainingFunction(node);\n if (!containingFunction) return;\n\n // Check each tracked event handler to see if this access is problematic\n for (const [eventHandler, eventParam] of eventHandlerParams) {\n // Check if this is accessing the event parameter\n if (objectName !== eventParam.name) continue;\n\n // Check if the access is inside a nested function within the event handler\n if (isInsideNestedFunction(node, eventHandler)) {\n context.report({\n node,\n messageId: 'noCurrentTargetInCallback',\n });\n return;\n }\n }\n },\n\n // Clean up when leaving event handlers\n 'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression:exit': (\n node: TSESTree.ArrowFunctionExpression,\n ) => {\n eventHandlerParams.delete(node);\n },\n 'JSXAttribute > JSXExpressionContainer > FunctionExpression:exit': (\n node: TSESTree.FunctionExpression,\n ) => {\n eventHandlerParams.delete(node);\n },\n };\n },\n});\n"]}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
4
|
+
/**
|
|
5
|
+
* This rule detects when `event.currentTarget` is accessed inside a nested
|
|
6
|
+
* callback function within a React event handler. This is problematic because
|
|
7
|
+
* React may execute callbacks (like those passed to setState) asynchronously,
|
|
8
|
+
* at which point `currentTarget` may already be nullified.
|
|
9
|
+
*
|
|
10
|
+
* Bad:
|
|
11
|
+
* ```tsx
|
|
12
|
+
* onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))}
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Good:
|
|
16
|
+
* ```tsx
|
|
17
|
+
* onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))}
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
exports.default = utils_1.ESLintUtils.RuleCreator.withoutDocs({
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'problem',
|
|
23
|
+
messages: {
|
|
24
|
+
noCurrentTargetInCallback: 'Accessing event.currentTarget inside a callback may fail because currentTarget can be nullified before the callback runs. Destructure currentTarget at the start of the event handler instead.',
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
defaultOptions: [],
|
|
29
|
+
create(context) {
|
|
30
|
+
// Track event handler parameters and their scopes
|
|
31
|
+
const eventHandlerParams = new Map();
|
|
32
|
+
/** Check if a node is inside a nested function relative to the event handler */
|
|
33
|
+
function isInsideNestedFunction(node, eventHandlerFunction) {
|
|
34
|
+
let current = node.parent;
|
|
35
|
+
let foundNestedFunction = false;
|
|
36
|
+
while (current && current !== eventHandlerFunction) {
|
|
37
|
+
if (current.type === 'ArrowFunctionExpression' ||
|
|
38
|
+
current.type === 'FunctionExpression' ||
|
|
39
|
+
current.type === 'FunctionDeclaration') {
|
|
40
|
+
foundNestedFunction = true;
|
|
41
|
+
}
|
|
42
|
+
current = current.parent;
|
|
43
|
+
}
|
|
44
|
+
return foundNestedFunction && current === eventHandlerFunction;
|
|
45
|
+
}
|
|
46
|
+
/** Find the function that contains this node */
|
|
47
|
+
function findContainingFunction(node) {
|
|
48
|
+
let current = node.parent;
|
|
49
|
+
while (current) {
|
|
50
|
+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
current = current.parent;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/** Check if a function is a JSX event handler */
|
|
58
|
+
function isJSXEventHandler(func) {
|
|
59
|
+
const parent = func.parent;
|
|
60
|
+
// Check for JSX attribute like onChange={...}
|
|
61
|
+
if (parent?.type === 'JSXExpressionContainer') {
|
|
62
|
+
const jsxAttribute = parent.parent;
|
|
63
|
+
if (jsxAttribute?.type === 'JSXAttribute') {
|
|
64
|
+
const attrName = jsxAttribute.name.type === 'JSXIdentifier' ? jsxAttribute.name.name : '';
|
|
65
|
+
// Match common event handler patterns
|
|
66
|
+
return /^on[A-Z]/.test(attrName);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/** Get the first parameter of an event handler if it looks like an event */
|
|
72
|
+
function getEventParameter(func) {
|
|
73
|
+
const firstParam = func.params[0];
|
|
74
|
+
if (!firstParam)
|
|
75
|
+
return null;
|
|
76
|
+
// Simple identifier parameter like (e) => ...
|
|
77
|
+
if (firstParam.type === 'Identifier') {
|
|
78
|
+
return firstParam;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
// When we enter a JSX event handler, track its event parameter
|
|
84
|
+
'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression': (node) => {
|
|
85
|
+
if (isJSXEventHandler(node)) {
|
|
86
|
+
const eventParam = getEventParameter(node);
|
|
87
|
+
if (eventParam) {
|
|
88
|
+
eventHandlerParams.set(node, eventParam);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
'JSXAttribute > JSXExpressionContainer > FunctionExpression': (node) => {
|
|
93
|
+
if (isJSXEventHandler(node)) {
|
|
94
|
+
const eventParam = getEventParameter(node);
|
|
95
|
+
if (eventParam) {
|
|
96
|
+
eventHandlerParams.set(node, eventParam);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
// Check for .currentTarget access
|
|
101
|
+
MemberExpression(node) {
|
|
102
|
+
// Only check for .currentTarget property access
|
|
103
|
+
if (node.property.type !== 'Identifier' || node.property.name !== 'currentTarget') {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Check if the object is an identifier (like `e` in `e.currentTarget`)
|
|
107
|
+
if (node.object.type !== 'Identifier') {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const objectName = node.object.name;
|
|
111
|
+
// Find the containing function
|
|
112
|
+
const containingFunction = findContainingFunction(node);
|
|
113
|
+
if (!containingFunction)
|
|
114
|
+
return;
|
|
115
|
+
// Check each tracked event handler to see if this access is problematic
|
|
116
|
+
for (const [eventHandler, eventParam] of eventHandlerParams) {
|
|
117
|
+
// Check if this is accessing the event parameter
|
|
118
|
+
if (objectName !== eventParam.name)
|
|
119
|
+
continue;
|
|
120
|
+
// Check if the access is inside a nested function within the event handler
|
|
121
|
+
if (isInsideNestedFunction(node, eventHandler)) {
|
|
122
|
+
context.report({
|
|
123
|
+
node,
|
|
124
|
+
messageId: 'noCurrentTargetInCallback',
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
// Clean up when leaving event handlers
|
|
131
|
+
'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression:exit': (node) => {
|
|
132
|
+
eventHandlerParams.delete(node);
|
|
133
|
+
},
|
|
134
|
+
'JSXAttribute > JSXExpressionContainer > FunctionExpression:exit': (node) => {
|
|
135
|
+
eventHandlerParams.delete(node);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
//# sourceMappingURL=no-current-target-in-callback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-current-target-in-callback.js","sourceRoot":"","sources":["../../src/rules/no-current-target-in-callback.ts"],"names":[],"mappings":"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","sourcesContent":["import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';\n\n/**\n * This rule detects when `event.currentTarget` is accessed inside a nested\n * callback function within a React event handler. This is problematic because\n * React may execute callbacks (like those passed to setState) asynchronously,\n * at which point `currentTarget` may already be nullified.\n *\n * Bad:\n * ```tsx\n * onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))}\n * ```\n *\n * Good:\n * ```tsx\n * onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))}\n * ```\n */\nexport default ESLintUtils.RuleCreator.withoutDocs({\n meta: {\n type: 'problem',\n messages: {\n noCurrentTargetInCallback:\n 'Accessing event.currentTarget inside a callback may fail because currentTarget can be nullified before the callback runs. Destructure currentTarget at the start of the event handler instead.',\n },\n schema: [],\n },\n defaultOptions: [],\n\n create(context) {\n // Track event handler parameters and their scopes\n const eventHandlerParams = new Map<TSESTree.Node, TSESTree.Identifier>();\n\n /** Check if a node is inside a nested function relative to the event handler */\n function isInsideNestedFunction(\n node: TSESTree.Node,\n eventHandlerFunction: TSESTree.Node,\n ): boolean {\n let current: TSESTree.Node | undefined = node.parent;\n let foundNestedFunction = false;\n\n while (current && current !== eventHandlerFunction) {\n if (\n current.type === 'ArrowFunctionExpression' ||\n current.type === 'FunctionExpression' ||\n current.type === 'FunctionDeclaration'\n ) {\n foundNestedFunction = true;\n }\n current = current.parent;\n }\n\n return foundNestedFunction && current === eventHandlerFunction;\n }\n\n /** Find the function that contains this node */\n function findContainingFunction(\n node: TSESTree.Node,\n ): TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | null {\n let current: TSESTree.Node | undefined = node.parent;\n\n while (current) {\n if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {\n return current;\n }\n current = current.parent;\n }\n\n return null;\n }\n\n /** Check if a function is a JSX event handler */\n function isJSXEventHandler(\n func: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,\n ): boolean {\n const parent = func.parent;\n\n // Check for JSX attribute like onChange={...}\n if (parent?.type === 'JSXExpressionContainer') {\n const jsxAttribute = parent.parent;\n if (jsxAttribute?.type === 'JSXAttribute') {\n const attrName = jsxAttribute.name.type === 'JSXIdentifier' ? jsxAttribute.name.name : '';\n // Match common event handler patterns\n return /^on[A-Z]/.test(attrName);\n }\n }\n\n return false;\n }\n\n /** Get the first parameter of an event handler if it looks like an event */\n function getEventParameter(\n func: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,\n ): TSESTree.Identifier | null {\n const firstParam = func.params[0];\n\n if (!firstParam) return null;\n\n // Simple identifier parameter like (e) => ...\n if (firstParam.type === 'Identifier') {\n return firstParam;\n }\n\n return null;\n }\n\n return {\n // When we enter a JSX event handler, track its event parameter\n 'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression': (\n node: TSESTree.ArrowFunctionExpression,\n ) => {\n if (isJSXEventHandler(node)) {\n const eventParam = getEventParameter(node);\n if (eventParam) {\n eventHandlerParams.set(node, eventParam);\n }\n }\n },\n 'JSXAttribute > JSXExpressionContainer > FunctionExpression': (\n node: TSESTree.FunctionExpression,\n ) => {\n if (isJSXEventHandler(node)) {\n const eventParam = getEventParameter(node);\n if (eventParam) {\n eventHandlerParams.set(node, eventParam);\n }\n }\n },\n\n // Check for .currentTarget access\n MemberExpression(node) {\n // Only check for .currentTarget property access\n if (node.property.type !== 'Identifier' || node.property.name !== 'currentTarget') {\n return;\n }\n\n // Check if the object is an identifier (like `e` in `e.currentTarget`)\n if (node.object.type !== 'Identifier') {\n return;\n }\n\n const objectName = node.object.name;\n\n // Find the containing function\n const containingFunction = findContainingFunction(node);\n if (!containingFunction) return;\n\n // Check each tracked event handler to see if this access is problematic\n for (const [eventHandler, eventParam] of eventHandlerParams) {\n // Check if this is accessing the event parameter\n if (objectName !== eventParam.name) continue;\n\n // Check if the access is inside a nested function within the event handler\n if (isInsideNestedFunction(node, eventHandler)) {\n context.report({\n node,\n messageId: 'noCurrentTargetInCallback',\n });\n return;\n }\n }\n },\n\n // Clean up when leaving event handlers\n 'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression:exit': (\n node: TSESTree.ArrowFunctionExpression,\n ) => {\n eventHandlerParams.delete(node);\n },\n 'JSXAttribute > JSXExpressionContainer > FunctionExpression:exit': (\n node: TSESTree.FunctionExpression,\n ) => {\n eventHandlerParams.delete(node);\n },\n };\n },\n});\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-current-target-in-callback.test.d.ts","sourceRoot":"","sources":["../../src/tests/no-current-target-in-callback.test.ts"],"names":[],"mappings":"","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/no-current-target-in-callback.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester({\n languageOptions: {\n parserOptions: {\n ecmaFeatures: {\n jsx: true,\n },\n },\n },\n});\n\nruleTester.run('no-current-target-in-callback', rule, {\n valid: [\n // Direct access to currentTarget (no nesting) is fine\n {\n code: '<input onChange={(e) => console.log(e.currentTarget.value)} />',\n },\n // Destructuring at the handler level is the recommended pattern\n {\n code: '<input onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))} />',\n },\n // Using a local variable is fine\n {\n code: `<input onChange={(e) => {\n const target = e.currentTarget;\n setChecks((c) => ({ ...c, value: target.checked }));\n }} />`,\n },\n // Non-event handlers are not checked\n {\n code: '<Component render={(e) => setFoo(() => e.currentTarget)} />',\n },\n // Direct setState without callback\n {\n code: '<input onChange={(e) => setChecks({ value: e.currentTarget.checked })} />',\n },\n // Event target (not currentTarget) - different issue, not covered by this rule\n {\n code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.target.checked }))} />',\n },\n ],\n invalid: [\n // Basic case: accessing e.currentTarget inside setState callback\n {\n code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Multiple accesses\n {\n code: `<input onChange={(e) => setChecks((c) => ({\n ...c,\n value: e.currentTarget.checked,\n name: e.currentTarget.name\n }))} />`,\n errors: [\n { messageId: 'noCurrentTargetInCallback' },\n { messageId: 'noCurrentTargetInCallback' },\n ],\n },\n // Different event parameter names\n {\n code: '<input onChange={(event) => setChecks((c) => ({ ...c, value: event.currentTarget.checked }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n {\n code: '<input onChange={(evt) => setChecks((c) => ({ ...c, value: evt.currentTarget.checked }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // onClick handler\n {\n code: '<button onClick={(e) => setFoo((prev) => ({ ...prev, clicked: e.currentTarget.id }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Nested arrow function in array method\n {\n code: '<input onChange={(e) => items.map(() => e.currentTarget.value)} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Function expression callback\n {\n code: '<input onChange={(e) => setChecks(function(c) { return { ...c, value: e.currentTarget.checked }; })} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Deeply nested\n {\n code: '<input onChange={(e) => outer(() => inner(() => e.currentTarget.value))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // With block body\n {\n code: `<input onChange={(e) => {\n setChecks((c) => {\n return { ...c, value: e.currentTarget.checked };\n });\n }} />`,\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n ],\n});\n"]}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const rule_tester_1 = require("@typescript-eslint/rule-tester");
|
|
7
|
+
const vitest_1 = require("vitest");
|
|
8
|
+
const no_current_target_in_callback_js_1 = __importDefault(require("../rules/no-current-target-in-callback.js"));
|
|
9
|
+
rule_tester_1.RuleTester.afterAll = vitest_1.afterAll;
|
|
10
|
+
rule_tester_1.RuleTester.describe = vitest_1.describe;
|
|
11
|
+
rule_tester_1.RuleTester.it = vitest_1.it;
|
|
12
|
+
const ruleTester = new rule_tester_1.RuleTester({
|
|
13
|
+
languageOptions: {
|
|
14
|
+
parserOptions: {
|
|
15
|
+
ecmaFeatures: {
|
|
16
|
+
jsx: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
ruleTester.run('no-current-target-in-callback', no_current_target_in_callback_js_1.default, {
|
|
22
|
+
valid: [
|
|
23
|
+
// Direct access to currentTarget (no nesting) is fine
|
|
24
|
+
{
|
|
25
|
+
code: '<input onChange={(e) => console.log(e.currentTarget.value)} />',
|
|
26
|
+
},
|
|
27
|
+
// Destructuring at the handler level is the recommended pattern
|
|
28
|
+
{
|
|
29
|
+
code: '<input onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))} />',
|
|
30
|
+
},
|
|
31
|
+
// Using a local variable is fine
|
|
32
|
+
{
|
|
33
|
+
code: `<input onChange={(e) => {
|
|
34
|
+
const target = e.currentTarget;
|
|
35
|
+
setChecks((c) => ({ ...c, value: target.checked }));
|
|
36
|
+
}} />`,
|
|
37
|
+
},
|
|
38
|
+
// Non-event handlers are not checked
|
|
39
|
+
{
|
|
40
|
+
code: '<Component render={(e) => setFoo(() => e.currentTarget)} />',
|
|
41
|
+
},
|
|
42
|
+
// Direct setState without callback
|
|
43
|
+
{
|
|
44
|
+
code: '<input onChange={(e) => setChecks({ value: e.currentTarget.checked })} />',
|
|
45
|
+
},
|
|
46
|
+
// Event target (not currentTarget) - different issue, not covered by this rule
|
|
47
|
+
{
|
|
48
|
+
code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.target.checked }))} />',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
invalid: [
|
|
52
|
+
// Basic case: accessing e.currentTarget inside setState callback
|
|
53
|
+
{
|
|
54
|
+
code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))} />',
|
|
55
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
56
|
+
},
|
|
57
|
+
// Multiple accesses
|
|
58
|
+
{
|
|
59
|
+
code: `<input onChange={(e) => setChecks((c) => ({
|
|
60
|
+
...c,
|
|
61
|
+
value: e.currentTarget.checked,
|
|
62
|
+
name: e.currentTarget.name
|
|
63
|
+
}))} />`,
|
|
64
|
+
errors: [
|
|
65
|
+
{ messageId: 'noCurrentTargetInCallback' },
|
|
66
|
+
{ messageId: 'noCurrentTargetInCallback' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
// Different event parameter names
|
|
70
|
+
{
|
|
71
|
+
code: '<input onChange={(event) => setChecks((c) => ({ ...c, value: event.currentTarget.checked }))} />',
|
|
72
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
code: '<input onChange={(evt) => setChecks((c) => ({ ...c, value: evt.currentTarget.checked }))} />',
|
|
76
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
77
|
+
},
|
|
78
|
+
// onClick handler
|
|
79
|
+
{
|
|
80
|
+
code: '<button onClick={(e) => setFoo((prev) => ({ ...prev, clicked: e.currentTarget.id }))} />',
|
|
81
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
82
|
+
},
|
|
83
|
+
// Nested arrow function in array method
|
|
84
|
+
{
|
|
85
|
+
code: '<input onChange={(e) => items.map(() => e.currentTarget.value)} />',
|
|
86
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
87
|
+
},
|
|
88
|
+
// Function expression callback
|
|
89
|
+
{
|
|
90
|
+
code: '<input onChange={(e) => setChecks(function(c) { return { ...c, value: e.currentTarget.checked }; })} />',
|
|
91
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
92
|
+
},
|
|
93
|
+
// Deeply nested
|
|
94
|
+
{
|
|
95
|
+
code: '<input onChange={(e) => outer(() => inner(() => e.currentTarget.value))} />',
|
|
96
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
97
|
+
},
|
|
98
|
+
// With block body
|
|
99
|
+
{
|
|
100
|
+
code: `<input onChange={(e) => {
|
|
101
|
+
setChecks((c) => {
|
|
102
|
+
return { ...c, value: e.currentTarget.checked };
|
|
103
|
+
});
|
|
104
|
+
}} />`,
|
|
105
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
//# sourceMappingURL=no-current-target-in-callback.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-current-target-in-callback.test.js","sourceRoot":"","sources":["../../src/tests/no-current-target-in-callback.test.ts"],"names":[],"mappings":";;;;;AAAA,gEAA4D;AAC5D,mCAAgD;AAEhD,iHAA6D;AAE7D,wBAAU,CAAC,QAAQ,GAAG,iBAAQ,CAAC;AAC/B,wBAAU,CAAC,QAAQ,GAAG,iBAAQ,CAAC;AAC/B,wBAAU,CAAC,EAAE,GAAG,WAAE,CAAC;AAEnB,MAAM,UAAU,GAAG,IAAI,wBAAU,CAAC;IAChC,eAAe,EAAE;QACf,aAAa,EAAE;YACb,YAAY,EAAE;gBACZ,GAAG,EAAE,IAAI;aACV;SACF;KACF;CACF,CAAC,CAAC;AAEH,UAAU,CAAC,GAAG,CAAC,+BAA+B,EAAE,0CAAI,EAAE;IACpD,KAAK,EAAE;QACL,sDAAsD;QACtD;YACE,IAAI,EAAE,gEAAgE;SACvE;QACD,gEAAgE;QAChE;YACE,IAAI,EAAE,wGAAwG;SAC/G;QACD,iCAAiC;QACjC;YACE,IAAI,EAAE;;;YAGA;SACP;QACD,qCAAqC;QACrC;YACE,IAAI,EAAE,6DAA6D;SACpE;QACD,mCAAmC;QACnC;YACE,IAAI,EAAE,2EAA2E;SAClF;QACD,+EAA+E;QAC/E;YACE,IAAI,EAAE,mFAAmF;SAC1F;KACF;IACD,OAAO,EAAE;QACP,iEAAiE;QACjE;YACE,IAAI,EAAE,0FAA0F;YAChG,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;QACD,oBAAoB;QACpB;YACE,IAAI,EAAE;;;;cAIE;YACR,MAAM,EAAE;gBACN,EAAE,SAAS,EAAE,2BAA2B,EAAE;gBAC1C,EAAE,SAAS,EAAE,2BAA2B,EAAE;aAC3C;SACF;QACD,kCAAkC;QAClC;YACE,IAAI,EAAE,kGAAkG;YACxG,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;QACD;YACE,IAAI,EAAE,8FAA8F;YACpG,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;QACD,kBAAkB;QAClB;YACE,IAAI,EAAE,0FAA0F;YAChG,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;QACD,wCAAwC;QACxC;YACE,IAAI,EAAE,oEAAoE;YAC1E,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;QACD,+BAA+B;QAC/B;YACE,IAAI,EAAE,yGAAyG;YAC/G,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;QACD,gBAAgB;QAChB;YACE,IAAI,EAAE,6EAA6E;YACnF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;QACD,kBAAkB;QAClB;YACE,IAAI,EAAE;;;;YAIA;YACN,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAAC;SACrD;KACF;CACF,CAAC,CAAC","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/no-current-target-in-callback.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester({\n languageOptions: {\n parserOptions: {\n ecmaFeatures: {\n jsx: true,\n },\n },\n },\n});\n\nruleTester.run('no-current-target-in-callback', rule, {\n valid: [\n // Direct access to currentTarget (no nesting) is fine\n {\n code: '<input onChange={(e) => console.log(e.currentTarget.value)} />',\n },\n // Destructuring at the handler level is the recommended pattern\n {\n code: '<input onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))} />',\n },\n // Using a local variable is fine\n {\n code: `<input onChange={(e) => {\n const target = e.currentTarget;\n setChecks((c) => ({ ...c, value: target.checked }));\n }} />`,\n },\n // Non-event handlers are not checked\n {\n code: '<Component render={(e) => setFoo(() => e.currentTarget)} />',\n },\n // Direct setState without callback\n {\n code: '<input onChange={(e) => setChecks({ value: e.currentTarget.checked })} />',\n },\n // Event target (not currentTarget) - different issue, not covered by this rule\n {\n code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.target.checked }))} />',\n },\n ],\n invalid: [\n // Basic case: accessing e.currentTarget inside setState callback\n {\n code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Multiple accesses\n {\n code: `<input onChange={(e) => setChecks((c) => ({\n ...c,\n value: e.currentTarget.checked,\n name: e.currentTarget.name\n }))} />`,\n errors: [\n { messageId: 'noCurrentTargetInCallback' },\n { messageId: 'noCurrentTargetInCallback' },\n ],\n },\n // Different event parameter names\n {\n code: '<input onChange={(event) => setChecks((c) => ({ ...c, value: event.currentTarget.checked }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n {\n code: '<input onChange={(evt) => setChecks((c) => ({ ...c, value: evt.currentTarget.checked }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // onClick handler\n {\n code: '<button onClick={(e) => setFoo((prev) => ({ ...prev, clicked: e.currentTarget.id }))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Nested arrow function in array method\n {\n code: '<input onChange={(e) => items.map(() => e.currentTarget.value)} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Function expression callback\n {\n code: '<input onChange={(e) => setChecks(function(c) { return { ...c, value: e.currentTarget.checked }; })} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // Deeply nested\n {\n code: '<input onChange={(e) => outer(() => inner(() => e.currentTarget.value))} />',\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n // With block body\n {\n code: `<input onChange={(e) => {\n setChecks((c) => {\n return { ...c, value: e.currentTarget.checked };\n });\n }} />`,\n errors: [{ messageId: 'noCurrentTargetInCallback' }],\n },\n ],\n});\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/eslint-plugin",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/PrairieLearn/PrairieLearn.git",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@types/node": "^24.10.9",
|
|
25
25
|
"@typescript-eslint/rule-tester": "^8.54.0",
|
|
26
26
|
"@typescript-eslint/types": "^8.54.0",
|
|
27
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
27
|
+
"@typescript/native-preview": "^7.0.0-dev.20260203.1",
|
|
28
28
|
"@vitest/coverage-v8": "^4.0.18",
|
|
29
29
|
"tsx": "^4.21.0",
|
|
30
30
|
"vitest": "^4.0.18"
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import awsClientMandatoryConfig from './rules/aws-client-mandatory-config.js';
|
|
2
2
|
import awsClientSharedConfig from './rules/aws-client-shared-config.js';
|
|
3
3
|
import jsxNoDollarInterpolation from './rules/jsx-no-dollar-interpolation.js';
|
|
4
|
+
import noCurrentTargetInCallback from './rules/no-current-target-in-callback.js';
|
|
4
5
|
import noUnusedSqlBlocks from './rules/no-unused-sql-blocks.js';
|
|
5
6
|
import safeDbTypes from './rules/safe-db-types.js';
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ export const rules = {
|
|
|
8
9
|
'aws-client-mandatory-config': awsClientMandatoryConfig,
|
|
9
10
|
'aws-client-shared-config': awsClientSharedConfig,
|
|
10
11
|
'jsx-no-dollar-interpolation': jsxNoDollarInterpolation,
|
|
12
|
+
'no-current-target-in-callback': noCurrentTargetInCallback,
|
|
11
13
|
'no-unused-sql-blocks': noUnusedSqlBlocks,
|
|
12
14
|
'safe-db-types': safeDbTypes,
|
|
13
15
|
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This rule detects when `event.currentTarget` is accessed inside a nested
|
|
5
|
+
* callback function within a React event handler. This is problematic because
|
|
6
|
+
* React may execute callbacks (like those passed to setState) asynchronously,
|
|
7
|
+
* at which point `currentTarget` may already be nullified.
|
|
8
|
+
*
|
|
9
|
+
* Bad:
|
|
10
|
+
* ```tsx
|
|
11
|
+
* onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))}
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Good:
|
|
15
|
+
* ```tsx
|
|
16
|
+
* onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))}
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export default ESLintUtils.RuleCreator.withoutDocs({
|
|
20
|
+
meta: {
|
|
21
|
+
type: 'problem',
|
|
22
|
+
messages: {
|
|
23
|
+
noCurrentTargetInCallback:
|
|
24
|
+
'Accessing event.currentTarget inside a callback may fail because currentTarget can be nullified before the callback runs. Destructure currentTarget at the start of the event handler instead.',
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
defaultOptions: [],
|
|
29
|
+
|
|
30
|
+
create(context) {
|
|
31
|
+
// Track event handler parameters and their scopes
|
|
32
|
+
const eventHandlerParams = new Map<TSESTree.Node, TSESTree.Identifier>();
|
|
33
|
+
|
|
34
|
+
/** Check if a node is inside a nested function relative to the event handler */
|
|
35
|
+
function isInsideNestedFunction(
|
|
36
|
+
node: TSESTree.Node,
|
|
37
|
+
eventHandlerFunction: TSESTree.Node,
|
|
38
|
+
): boolean {
|
|
39
|
+
let current: TSESTree.Node | undefined = node.parent;
|
|
40
|
+
let foundNestedFunction = false;
|
|
41
|
+
|
|
42
|
+
while (current && current !== eventHandlerFunction) {
|
|
43
|
+
if (
|
|
44
|
+
current.type === 'ArrowFunctionExpression' ||
|
|
45
|
+
current.type === 'FunctionExpression' ||
|
|
46
|
+
current.type === 'FunctionDeclaration'
|
|
47
|
+
) {
|
|
48
|
+
foundNestedFunction = true;
|
|
49
|
+
}
|
|
50
|
+
current = current.parent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return foundNestedFunction && current === eventHandlerFunction;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Find the function that contains this node */
|
|
57
|
+
function findContainingFunction(
|
|
58
|
+
node: TSESTree.Node,
|
|
59
|
+
): TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | null {
|
|
60
|
+
let current: TSESTree.Node | undefined = node.parent;
|
|
61
|
+
|
|
62
|
+
while (current) {
|
|
63
|
+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
current = current.parent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Check if a function is a JSX event handler */
|
|
73
|
+
function isJSXEventHandler(
|
|
74
|
+
func: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
|
|
75
|
+
): boolean {
|
|
76
|
+
const parent = func.parent;
|
|
77
|
+
|
|
78
|
+
// Check for JSX attribute like onChange={...}
|
|
79
|
+
if (parent?.type === 'JSXExpressionContainer') {
|
|
80
|
+
const jsxAttribute = parent.parent;
|
|
81
|
+
if (jsxAttribute?.type === 'JSXAttribute') {
|
|
82
|
+
const attrName = jsxAttribute.name.type === 'JSXIdentifier' ? jsxAttribute.name.name : '';
|
|
83
|
+
// Match common event handler patterns
|
|
84
|
+
return /^on[A-Z]/.test(attrName);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Get the first parameter of an event handler if it looks like an event */
|
|
92
|
+
function getEventParameter(
|
|
93
|
+
func: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
|
|
94
|
+
): TSESTree.Identifier | null {
|
|
95
|
+
const firstParam = func.params[0];
|
|
96
|
+
|
|
97
|
+
if (!firstParam) return null;
|
|
98
|
+
|
|
99
|
+
// Simple identifier parameter like (e) => ...
|
|
100
|
+
if (firstParam.type === 'Identifier') {
|
|
101
|
+
return firstParam;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
// When we enter a JSX event handler, track its event parameter
|
|
109
|
+
'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression': (
|
|
110
|
+
node: TSESTree.ArrowFunctionExpression,
|
|
111
|
+
) => {
|
|
112
|
+
if (isJSXEventHandler(node)) {
|
|
113
|
+
const eventParam = getEventParameter(node);
|
|
114
|
+
if (eventParam) {
|
|
115
|
+
eventHandlerParams.set(node, eventParam);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
'JSXAttribute > JSXExpressionContainer > FunctionExpression': (
|
|
120
|
+
node: TSESTree.FunctionExpression,
|
|
121
|
+
) => {
|
|
122
|
+
if (isJSXEventHandler(node)) {
|
|
123
|
+
const eventParam = getEventParameter(node);
|
|
124
|
+
if (eventParam) {
|
|
125
|
+
eventHandlerParams.set(node, eventParam);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// Check for .currentTarget access
|
|
131
|
+
MemberExpression(node) {
|
|
132
|
+
// Only check for .currentTarget property access
|
|
133
|
+
if (node.property.type !== 'Identifier' || node.property.name !== 'currentTarget') {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check if the object is an identifier (like `e` in `e.currentTarget`)
|
|
138
|
+
if (node.object.type !== 'Identifier') {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const objectName = node.object.name;
|
|
143
|
+
|
|
144
|
+
// Find the containing function
|
|
145
|
+
const containingFunction = findContainingFunction(node);
|
|
146
|
+
if (!containingFunction) return;
|
|
147
|
+
|
|
148
|
+
// Check each tracked event handler to see if this access is problematic
|
|
149
|
+
for (const [eventHandler, eventParam] of eventHandlerParams) {
|
|
150
|
+
// Check if this is accessing the event parameter
|
|
151
|
+
if (objectName !== eventParam.name) continue;
|
|
152
|
+
|
|
153
|
+
// Check if the access is inside a nested function within the event handler
|
|
154
|
+
if (isInsideNestedFunction(node, eventHandler)) {
|
|
155
|
+
context.report({
|
|
156
|
+
node,
|
|
157
|
+
messageId: 'noCurrentTargetInCallback',
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// Clean up when leaving event handlers
|
|
165
|
+
'JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression:exit': (
|
|
166
|
+
node: TSESTree.ArrowFunctionExpression,
|
|
167
|
+
) => {
|
|
168
|
+
eventHandlerParams.delete(node);
|
|
169
|
+
},
|
|
170
|
+
'JSXAttribute > JSXExpressionContainer > FunctionExpression:exit': (
|
|
171
|
+
node: TSESTree.FunctionExpression,
|
|
172
|
+
) => {
|
|
173
|
+
eventHandlerParams.delete(node);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { afterAll, describe, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import rule from '../rules/no-current-target-in-callback.js';
|
|
5
|
+
|
|
6
|
+
RuleTester.afterAll = afterAll;
|
|
7
|
+
RuleTester.describe = describe;
|
|
8
|
+
RuleTester.it = it;
|
|
9
|
+
|
|
10
|
+
const ruleTester = new RuleTester({
|
|
11
|
+
languageOptions: {
|
|
12
|
+
parserOptions: {
|
|
13
|
+
ecmaFeatures: {
|
|
14
|
+
jsx: true,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
ruleTester.run('no-current-target-in-callback', rule, {
|
|
21
|
+
valid: [
|
|
22
|
+
// Direct access to currentTarget (no nesting) is fine
|
|
23
|
+
{
|
|
24
|
+
code: '<input onChange={(e) => console.log(e.currentTarget.value)} />',
|
|
25
|
+
},
|
|
26
|
+
// Destructuring at the handler level is the recommended pattern
|
|
27
|
+
{
|
|
28
|
+
code: '<input onChange={({ currentTarget }) => setChecks((c) => ({ ...c, value: currentTarget.checked }))} />',
|
|
29
|
+
},
|
|
30
|
+
// Using a local variable is fine
|
|
31
|
+
{
|
|
32
|
+
code: `<input onChange={(e) => {
|
|
33
|
+
const target = e.currentTarget;
|
|
34
|
+
setChecks((c) => ({ ...c, value: target.checked }));
|
|
35
|
+
}} />`,
|
|
36
|
+
},
|
|
37
|
+
// Non-event handlers are not checked
|
|
38
|
+
{
|
|
39
|
+
code: '<Component render={(e) => setFoo(() => e.currentTarget)} />',
|
|
40
|
+
},
|
|
41
|
+
// Direct setState without callback
|
|
42
|
+
{
|
|
43
|
+
code: '<input onChange={(e) => setChecks({ value: e.currentTarget.checked })} />',
|
|
44
|
+
},
|
|
45
|
+
// Event target (not currentTarget) - different issue, not covered by this rule
|
|
46
|
+
{
|
|
47
|
+
code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.target.checked }))} />',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
invalid: [
|
|
51
|
+
// Basic case: accessing e.currentTarget inside setState callback
|
|
52
|
+
{
|
|
53
|
+
code: '<input onChange={(e) => setChecks((c) => ({ ...c, value: e.currentTarget.checked }))} />',
|
|
54
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
55
|
+
},
|
|
56
|
+
// Multiple accesses
|
|
57
|
+
{
|
|
58
|
+
code: `<input onChange={(e) => setChecks((c) => ({
|
|
59
|
+
...c,
|
|
60
|
+
value: e.currentTarget.checked,
|
|
61
|
+
name: e.currentTarget.name
|
|
62
|
+
}))} />`,
|
|
63
|
+
errors: [
|
|
64
|
+
{ messageId: 'noCurrentTargetInCallback' },
|
|
65
|
+
{ messageId: 'noCurrentTargetInCallback' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
// Different event parameter names
|
|
69
|
+
{
|
|
70
|
+
code: '<input onChange={(event) => setChecks((c) => ({ ...c, value: event.currentTarget.checked }))} />',
|
|
71
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
code: '<input onChange={(evt) => setChecks((c) => ({ ...c, value: evt.currentTarget.checked }))} />',
|
|
75
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
76
|
+
},
|
|
77
|
+
// onClick handler
|
|
78
|
+
{
|
|
79
|
+
code: '<button onClick={(e) => setFoo((prev) => ({ ...prev, clicked: e.currentTarget.id }))} />',
|
|
80
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
81
|
+
},
|
|
82
|
+
// Nested arrow function in array method
|
|
83
|
+
{
|
|
84
|
+
code: '<input onChange={(e) => items.map(() => e.currentTarget.value)} />',
|
|
85
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
86
|
+
},
|
|
87
|
+
// Function expression callback
|
|
88
|
+
{
|
|
89
|
+
code: '<input onChange={(e) => setChecks(function(c) { return { ...c, value: e.currentTarget.checked }; })} />',
|
|
90
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
91
|
+
},
|
|
92
|
+
// Deeply nested
|
|
93
|
+
{
|
|
94
|
+
code: '<input onChange={(e) => outer(() => inner(() => e.currentTarget.value))} />',
|
|
95
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
96
|
+
},
|
|
97
|
+
// With block body
|
|
98
|
+
{
|
|
99
|
+
code: `<input onChange={(e) => {
|
|
100
|
+
setChecks((c) => {
|
|
101
|
+
return { ...c, value: e.currentTarget.checked };
|
|
102
|
+
});
|
|
103
|
+
}} />`,
|
|
104
|
+
errors: [{ messageId: 'noCurrentTargetInCallback' }],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|