Skip to content

Commit e9dd937

Browse files
committed
[Tests] Component 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 d56fdb8 commit e9dd937

File tree

2 files changed

+267
-9
lines changed

2 files changed

+267
-9
lines changed

lib/util/Components.js

Lines changed: 79 additions & 0 deletions
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,82 @@ 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 defaultReactImportSpecifier = defaultReactImports
810+
? defaultReactImports[0]
811+
: undefined;
812+
813+
const defaultReactImportName = defaultReactImportSpecifier
814+
? defaultReactImportSpecifier.local.name
815+
: undefined;
816+
817+
const reactHookImportSpecifiers = namedReactImports
818+
? namedReactImports.filter((specifier) => USE_HOOK_PREFIX_REGEX.test(specifier.imported.name))
819+
: undefined;
820+
const reactHookImportNames = reactHookImportSpecifiers
821+
&& fromEntries(reactHookImportSpecifiers.map((specifier) => [specifier.local.name, specifier.imported.name]));
822+
823+
const isPotentialReactHookCall = defaultReactImportName
824+
&& node.callee.type === 'MemberExpression'
825+
&& node.callee.object.type === 'Identifier'
826+
&& node.callee.object.name === defaultReactImportName
827+
&& node.callee.property.type === 'Identifier'
828+
&& node.callee.property.name.match(USE_HOOK_PREFIX_REGEX);
829+
830+
const isPotentialHookCall = reactHookImportNames
831+
&& node.callee.type === 'Identifier'
832+
&& node.callee.name.match(USE_HOOK_PREFIX_REGEX);
833+
834+
const scope = isPotentialReactHookCall || isPotentialHookCall
835+
? context.getScope()
836+
: undefined;
837+
838+
const reactResolvedDefs = isPotentialReactHookCall
839+
&& scope.references
840+
&& scope.references.find(
841+
(reference) => reference.identifier.name === defaultReactImportName
842+
).resolved.defs;
843+
const potentialHookReference = isPotentialHookCall
844+
&& scope.references
845+
&& scope.references.find(
846+
(reference) => reactHookImportNames[reference.identifier.name]
847+
);
848+
const hookResolvedDefs = potentialHookReference && potentialHookReference.resolved.defs;
849+
850+
const hookName = (isPotentialReactHookCall && node.callee.property.name)
851+
|| (isPotentialHookCall && potentialHookReference && node.callee.name);
852+
const normalizedHookName = (reactHookImportNames && reactHookImportNames[hookName]) || hookName;
853+
854+
const isReactShadowed = isPotentialReactHookCall && reactResolvedDefs
855+
&& reactResolvedDefs.some((reactDef) => reactDef.type !== 'ImportBinding');
856+
857+
const isHookShadowed = isPotentialHookCall
858+
&& hookResolvedDefs
859+
&& hookResolvedDefs.some(
860+
(hookDef) => hookDef.name.name === hookName
861+
&& hookDef.type !== 'ImportBinding'
862+
);
863+
864+
const isHookCall = (isPotentialReactHookCall && !isReactShadowed)
865+
|| (isPotentialHookCall && hookName && !isHookShadowed);
866+
return !!(isHookCall
867+
&& (!expectedHookNames || arrayIncludes(expectedHookNames, normalizedHookName)));
868+
},
790869
};
791870

792871
// Component detection instructions

tests/util/Components.js

Lines changed: 188 additions & 9 deletions
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,161 @@ 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 not identify shadowed React hook call', () => {
221+
testComponentsDetect({
222+
code: `import React from 'react'
223+
function useColor() {
224+
const React = {
225+
useState: () => null
226+
}
227+
return React.useState()
228+
}`,
229+
}, {
230+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
231+
}, (_components, instructionResults) => {
232+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
233+
});
234+
});
235+
236+
it('should identify present named hook call', () => {
237+
testComponentsDetect({
238+
code: `import { useState } from 'react'
239+
function useColor() {
240+
return useState()
241+
}`,
242+
}, {
243+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
244+
}, (_components, instructionResults) => {
245+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
246+
});
247+
});
248+
249+
it('should identify present named React hook call', () => {
250+
testComponentsDetect({
251+
code: `import React from 'react'
252+
function useColor() {
253+
return React.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 not identify missing named hook call', () => {
263+
testComponentsDetect({
264+
code: `import { useState } from 'react'
265+
function useColor() {
266+
return useState()
267+
}`,
268+
}, {
269+
CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useRef']),
270+
}, (_components, instructionResults) => {
271+
assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
272+
});
273+
});
274+
});
275+
});
97276
});
98277
});

0 commit comments

Comments
 (0)