@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 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;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,KAAK;;;;;;;;CAMjB,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.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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=no-current-target-in-callback.test.d.ts.map
@@ -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.1",
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.20260130.1",
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
+ });