Skip to content

Commit b590a60

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 b590a60

File tree

2 files changed

+277
-9
lines changed

2 files changed

+277
-9
lines changed

lib/util/Components.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ function mergeUsedPropTypes(propsList, newPropsList) {
4646
return propsList.concat(propsToAdd);
4747
}
4848

49+
const USE_HOOK_PREFIX_REGEX = /^use/i;
50+
4951
const Lists = new WeakMap();
5052
const ReactImports = new WeakMap();
5153

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

792881
// 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)