Skip to content

Commit 5a25380

Browse files
duncanbeeversljharb
authored andcommitted
[New] component detection: add util.isReactHookCall
Rename Components test suite filename to match sibling lib/util/Components filename. Extend Components testComponentsDetect function to accept custom instructions, and to accumulate the results of processing those instructions. Add utility to check whether a CallExpression is a React hook call.
1 parent 3db5285 commit 5a25380

File tree

3 files changed

+296
-9
lines changed

3 files changed

+296
-9
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
77

88
### Added
99
* [`function-component-definition`]: support namedComponents option being an array ([#3129][] @petersendidit)
10+
* component detection: add `util.isReactHookCall` ([#3156][] @duncanbeevers)
1011

1112
### Fixed
1213
* [`jsx-indent-props`]: Reset `line.isUsingOperator` correctly after ternary ([#3146][] @tobiaswaltl)
@@ -16,6 +17,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
1617
* [Tests] component detection: Add testing scaffolding ([#3149][] @duncanbeevers)
1718
* [New] component detection: track React imports ([#3149][] @duncanbeevers)
1819

20+
[#3156]: https://github.com/yannickcr/eslint-plugin-react/pull/3156
1921
[#3149]: https://github.com/yannickcr/eslint-plugin-react/pull/3149
2022
[#3146]: https://github.com/yannickcr/eslint-plugin-react/pull/3146
2123
[#3129]: https://github.com/yannickcr/eslint-plugin-react/pull/3129

lib/util/Components.js

+81
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
const doctrine = require('doctrine');
99
const arrayIncludes = require('array-includes');
10+
const fromEntries = require('object.fromentries');
1011
const values = require('object.values');
1112

1213
const variableUtil = require('./variable');
@@ -46,6 +47,8 @@ function mergeUsedPropTypes(propsList, newPropsList) {
4647
return propsList.concat(propsToAdd);
4748
}
4849

50+
const USE_HOOK_PREFIX_REGEX = /^use[A-Z]/;
51+
4952
const Lists = new WeakMap();
5053
const ReactImports = new WeakMap();
5154

@@ -787,6 +790,84 @@ function componentRule(rule, context) {
787790
&& !!(node.params || []).length
788791
);
789792
},
793+
794+
/**
795+
* Identify whether a node (CallExpression) is a call to a React hook
796+
*
797+
* @param {ASTNode} node The AST node being searched. (expects CallExpression)
798+
* @param {('useCallback'|'useContext'|'useDebugValue'|'useEffect'|'useImperativeHandle'|'useLayoutEffect'|'useMemo'|'useReducer'|'useRef'|'useState')[]} [expectedHookNames] React hook names to which search is limited.
799+
* @returns {Boolean} True if the node is a call to a React hook
800+
*/
801+
isReactHookCall(node, expectedHookNames) {
802+
if (node.type !== 'CallExpression') {
803+
return false;
804+
}
805+
806+
const defaultReactImports = components.getDefaultReactImports();
807+
const namedReactImports = components.getNamedReactImports();
808+
809+
const defaultReactImportName = defaultReactImports
810+
&& defaultReactImports[0]
811+
&& defaultReactImports[0].local.name;
812+
const reactHookImportSpecifiers = namedReactImports
813+
&& namedReactImports.filter((specifier) => USE_HOOK_PREFIX_REGEX.test(specifier.imported.name));
814+
const reactHookImportNames = reactHookImportSpecifiers
815+
&& fromEntries(reactHookImportSpecifiers.map((specifier) => [specifier.local.name, specifier.imported.name]));
816+
817+
const isPotentialReactHookCall = defaultReactImportName
818+
&& node.callee.type === 'MemberExpression'
819+
&& node.callee.object.type === 'Identifier'
820+
&& node.callee.object.name === defaultReactImportName
821+
&& node.callee.property.type === 'Identifier'
822+
&& node.callee.property.name.match(USE_HOOK_PREFIX_REGEX);
823+
824+
const isPotentialHookCall = reactHookImportNames
825+
&& node.callee.type === 'Identifier'
826+
&& node.callee.name.match(USE_HOOK_PREFIX_REGEX);
827+
828+
const scope = (isPotentialReactHookCall || isPotentialHookCall) && context.getScope();
829+
830+
const reactResolvedDefs = isPotentialReactHookCall
831+
&& scope.references
832+
&& scope.references.find(
833+
(reference) => reference.identifier.name === defaultReactImportName
834+
).resolved.defs;
835+
836+
const isReactShadowed = isPotentialReactHookCall && reactResolvedDefs
837+
&& reactResolvedDefs.some((reactDef) => reactDef.type !== 'ImportBinding');
838+
839+
const potentialHookReference = isPotentialHookCall
840+
&& scope.references
841+
&& scope.references.find(
842+
(reference) => reactHookImportNames[reference.identifier.name]
843+
);
844+
845+
const hookResolvedDefs = potentialHookReference && potentialHookReference.resolved.defs;
846+
const localHookName = (isPotentialReactHookCall && node.callee.property.name)
847+
|| (isPotentialHookCall && potentialHookReference && node.callee.name);
848+
const isHookShadowed = isPotentialHookCall
849+
&& hookResolvedDefs
850+
&& hookResolvedDefs.some(
851+
(hookDef) => hookDef.name.name === localHookName
852+
&& hookDef.type !== 'ImportBinding'
853+
);
854+
855+
const isHookCall = (isPotentialReactHookCall && !isReactShadowed)
856+
|| (isPotentialHookCall && localHookName && !isHookShadowed);
857+
858+
if (!isHookCall) {
859+
return false;
860+
}
861+
862+
if (!expectedHookNames) {
863+
return true;
864+
}
865+
866+
return arrayIncludes(
867+
expectedHookNames,
868+
(reactHookImportNames && reactHookImportNames[localHookName]) || localHookName
869+
);
870+
},
790871
};
791872

792873
// Component detection instructions

tests/util/Components.js

+213-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
'use strict';
22

33
const assert = require('assert');
4+
const entries = require('object.entries');
45
const eslint = require('eslint');
6+
const fromEntries = require('object.fromentries');
57
const values = require('object.values');
68

79
const Components = require('../../lib/util/Components');
@@ -19,12 +21,32 @@ const ruleTester = new eslint.RuleTester({
1921

2022
describe('Components', () => {
2123
describe('static detect', () => {
22-
function testComponentsDetect(test, done) {
23-
const rule = Components.detect((context, components, util) => ({
24-
'Program:exit'() {
25-
done(context, components, util);
26-
},
27-
}));
24+
function testComponentsDetect(test, instructionsOrDone, orDone) {
25+
const done = orDone || instructionsOrDone;
26+
const instructions = orDone ? instructionsOrDone : instructionsOrDone;
27+
28+
const rule = Components.detect((_context, components, util) => {
29+
const instructionResults = [];
30+
31+
const augmentedInstructions = fromEntries(
32+
entries(instructions || {}).map((nodeTypeAndHandler) => {
33+
const nodeType = nodeTypeAndHandler[0];
34+
const handler = nodeTypeAndHandler[1];
35+
return [nodeType, (node) => {
36+
instructionResults.push({ type: nodeType, result: handler(node, context, components, util) });
37+
}];
38+
})
39+
);
40+
41+
return Object.assign({}, augmentedInstructions, {
42+
'Program:exit'(node) {
43+
if (augmentedInstructions['Program:exit']) {
44+
augmentedInstructions['Program:exit'](node, context, components, util);
45+
}
46+
done(components, instructionResults);
47+
},
48+
});
49+
});
2850

2951
const tests = {
3052
valid: parsers.all([Object.assign({}, test, {
@@ -36,6 +58,7 @@ describe('Components', () => {
3658
})]),
3759
invalid: [],
3860
};
61+
3962
ruleTester.run(test.code, rule, tests);
4063
}
4164

@@ -45,7 +68,7 @@ describe('Components', () => {
4568
function MyStatelessComponent() {
4669
return <React.Fragment />;
4770
}`,
48-
}, (_context, components) => {
71+
}, (components) => {
4972
assert.equal(components.length(), 1, 'MyStatelessComponent should be detected component');
5073
values(components.list()).forEach((component) => {
5174
assert.equal(
@@ -65,7 +88,7 @@ describe('Components', () => {
6588
return <React.Fragment />;
6689
}
6790
}`,
68-
}, (_context, components) => {
91+
}, (components) => {
6992
assert(components.length() === 1, 'MyClassComponent should be detected component');
7093
values(components.list()).forEach((component) => {
7194
assert.equal(
@@ -80,7 +103,7 @@ describe('Components', () => {
80103
it('should detect React Imports', () => {
81104
testComponentsDetect({
82105
code: 'import React, { useCallback, useState } from \'react\'',
83-
}, (_context, components) => {
106+
}, (components) => {
84107
assert.deepEqual(
85108
components.getDefaultReactImports().map((specifier) => specifier.local.name),
86109
['React'],
@@ -94,5 +117,186 @@ describe('Components', () => {
94117
);
95118
});
96119
});
120+
121+
describe('utils', () => {
122+
describe('isReactHookCall', () => {
123+
it('should not identify hook-like call', () => {
124+
testComponentsDetect({
125+
code: `import { useRef } from 'react'
126+
function useColor() {
127+
return useState()
128+
}`,
129+
}, {
130+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
131+
}, (_components, instructionResults) => {
132+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
133+
});
134+
});
135+
136+
it('should identify hook call', () => {
137+
testComponentsDetect({
138+
code: `import { useState } from 'react'
139+
function useColor() {
140+
return useState()
141+
}`,
142+
}, {
143+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
144+
}, (_components, instructionResults) => {
145+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
146+
});
147+
});
148+
149+
it('should identify aliased hook call', () => {
150+
testComponentsDetect({
151+
code: `import { useState as useStateAlternative } from 'react'
152+
function useColor() {
153+
return useStateAlternative()
154+
}`,
155+
}, {
156+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
157+
}, (_components, instructionResults) => {
158+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
159+
});
160+
});
161+
162+
it('should identify aliased present named hook call', () => {
163+
testComponentsDetect({
164+
code: `import { useState as useStateAlternative } from 'react'
165+
function useColor() {
166+
return useStateAlternative()
167+
}`,
168+
}, {
169+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
170+
}, (_components, instructionResults) => {
171+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
172+
});
173+
});
174+
175+
it('should not identify shadowed hook call', () => {
176+
testComponentsDetect({
177+
code: `import { useState } from 'react'
178+
function useColor() {
179+
function useState() {
180+
return null
181+
}
182+
return useState()
183+
}`,
184+
}, {
185+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
186+
}, (_components, instructionResults) => {
187+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
188+
});
189+
});
190+
191+
it('should not identify shadowed aliased present named hook call', () => {
192+
testComponentsDetect({
193+
code: `import { useState as useStateAlternative } from 'react'
194+
function useColor() {
195+
function useStateAlternative() {
196+
return null
197+
}
198+
return useStateAlternative()
199+
}`,
200+
}, {
201+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
202+
}, (_components, instructionResults) => {
203+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
204+
});
205+
});
206+
207+
it('should identify React hook call', () => {
208+
testComponentsDetect({
209+
code: `import React from 'react'
210+
function useColor() {
211+
return React.useState()
212+
}`,
213+
}, {
214+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
215+
}, (_components, instructionResults) => {
216+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
217+
});
218+
});
219+
220+
it('should identify aliased React hook call', () => {
221+
testComponentsDetect({
222+
code: `import ReactAlternative from 'react'
223+
function useColor() {
224+
return ReactAlternative.useState()
225+
}`,
226+
}, {
227+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
228+
}, (_components, instructionResults) => {
229+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
230+
});
231+
});
232+
233+
it('should not identify shadowed React hook call', () => {
234+
testComponentsDetect({
235+
code: `import React from 'react'
236+
function useColor() {
237+
const React = {
238+
useState: () => null
239+
}
240+
return React.useState()
241+
}`,
242+
}, {
243+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
244+
}, (_components, instructionResults) => {
245+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
246+
});
247+
});
248+
249+
it('should identify present named hook call', () => {
250+
testComponentsDetect({
251+
code: `import { useState } from 'react'
252+
function useColor() {
253+
return useState()
254+
}`,
255+
}, {
256+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
257+
}, (_components, instructionResults) => {
258+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
259+
});
260+
});
261+
262+
it('should identify present named React hook call', () => {
263+
testComponentsDetect({
264+
code: `import React from 'react'
265+
function useColor() {
266+
return React.useState()
267+
}`,
268+
}, {
269+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
270+
}, (_components, instructionResults) => {
271+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
272+
});
273+
});
274+
275+
it('should not identify missing named hook call', () => {
276+
testComponentsDetect({
277+
code: `import { useState } from 'react'
278+
function useColor() {
279+
return useState()
280+
}`,
281+
}, {
282+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useRef']),
283+
}, (_components, instructionResults) => {
284+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
285+
});
286+
});
287+
});
288+
});
289+
290+
describe('testComponentsDetect', () => {
291+
it('should log Program:exit instruction', () => {
292+
testComponentsDetect({
293+
code: '',
294+
}, {
295+
'Program:exit': () => true,
296+
}, (_components, instructionResults) => {
297+
assert.deepEqual(instructionResults, [{ type: 'Program:exit', result: true }]);
298+
});
299+
});
300+
});
97301
});
98302
});

0 commit comments

Comments
 (0)