Add ESLint rule for React Hooks

This commit is contained in:
Caleb Meredith 2018-10-23 12:35:56 -07:00 committed by Andrew Clark
parent acb4899637
commit ddbfe2ed50
8 changed files with 1302 additions and 0 deletions

View File

@ -0,0 +1,48 @@
# `eslint-plugin-react-hooks`
This ESLint plugin enforces the [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html).
It is a part of the [Hooks proposal](https://reactjs.org/docs/hooks-intro.html) for React.
## Experimental Status
This is an experimental release and is intended to be used for testing the Hooks proposal with React 16.7 alpha. The exact heuristics it uses may be adjusted.
The [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html) documentation contains a link to the technical RFC. Please leave a comment on the RFC if you have concerns or ideas about how this plugin should work.
## Installation
**Note: If you're using Create React App, please wait for a corresponding experimental release of `react-scripts` that includes this rule instead of adding it directly.**
Assuming you already have ESLint installed, run:
```sh
# npm
npm install eslint-plugin-react-hooks@next --save-dev
# yarn
yarn add eslint-plugin-react-hooks@next --dev
```
Then add it to your ESLint configuration:
```js
{
"plugins": [
// ...
"react-hooks"
],
"rules": {
// ...
"react-hooks/rules-of-hooks": "error"
}
}
```
## Valid and Invalid Examples
Please refer to the [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html) documentation and the [Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#what-exactly-do-the-lint-rules-enforce) to learn more about this rule.
## License
MIT

View File

@ -0,0 +1,632 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
const ESLintTester = require('eslint').RuleTester;
const ReactHooksESLintPlugin = require('eslint-plugin-react-hooks');
const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['rules-of-hooks'];
ESLintTester.setDefaultConfig({
parser: 'babel-eslint',
parserOptions: {
ecmaVersion: 6,
sourceType: 'module',
},
});
const eslintTester = new ESLintTester();
eslintTester.run('react-hooks', ReactHooksESLintRule, {
valid: [
`
// Valid because components can use hooks.
function ComponentWithHook() {
useHook();
}
`,
`
// Valid because components can use hooks.
function createComponentWithHook() {
return function ComponentWithHook() {
useHook();
};
}
`,
`
// Valid because hooks can use hooks.
function useHookWithHook() {
useHook();
}
`,
`
// Valid because hooks can use hooks.
function createHook() {
return function useHookWithHook() {
useHook();
}
}
`,
`
// Valid because components can call functions.
function ComponentWithNormalFunction() {
doSomething();
}
`,
`
// Valid because functions can call functions.
function normalFunctionWithNormalFunction() {
doSomething();
}
`,
`
// Valid because functions can call functions.
function normalFunctionWithConditionalFunction() {
if (cond) {
doSomething();
}
}
`,
`
// Valid because functions can call functions.
function functionThatStartsWithUseButIsntAHook() {
if (cond) {
userFetch();
}
}
`,
`
// Valid although unconditional return doesn't make sense and would fail other rules.
// We could make it invalid but it doesn't matter.
function useUnreachable() {
return;
useHook();
}
`,
`
// Valid because hooks can call hooks.
function useHook() { useState(); }
const whatever = function useHook() { useState(); };
const useHook1 = () => { useState(); };
let useHook2 = () => useState();
useHook2 = () => { useState(); };
({useHook: () => { useState(); }});
({useHook() { useState(); }});
const {useHook = () => { useState(); }} = {};
({useHook = () => { useState(); }} = {});
`,
`
// Valid because hooks can call hooks.
function useHook() {
useHook1();
useHook2();
}
`,
`
// Valid because hooks can call hooks.
function createHook() {
return function useHook() {
useHook1();
useHook2();
};
}
`,
`
// Valid because hooks can call hooks.
function useHook() {
useState() && a;
}
`,
`
// Valid because hooks can call hooks.
function useHook() {
return useHook1() + useHook2();
}
`,
`
// Valid because hooks can call hooks.
function useHook() {
return useHook1(useHook2());
}
`,
`
// Valid because classes can call functions.
// We don't consider these to be hooks.
class C {
m() {
this.useHook();
super.useHook();
}
}
`,
`
// Currently valid.
// We *could* make this invalid if we want, but it creates false positives
// (see the FooStore case).
class C {
m() {
This.useHook();
Super.useHook();
}
}
`,
`
// Valid although we *could* consider these invalid.
// But it doesn't bring much benefit since it's an immediate runtime error anyway.
// So might as well allow it.
Hook.use();
Hook._use();
Hook.useState();
Hook._useState();
Hook.use42();
Hook.useHook();
Hook.use_hook();
`,
`
// Valid -- this is a regression test.
jest.useFakeTimers();
beforeEach(() => {
jest.useRealTimers();
})
`,
`
// Valid because that's a false positive we've seen quite a bit.
// This is a regression test.
class Foo extends Component {
render() {
if (cond) {
FooStore.useFeatureFlag();
}
}
}
`,
`
// Currently valid because we found this to be a common pattern
// for feature flag checks in existing components.
// We *could* make it invalid but that produces quite a few false positives.
// Why does it make sense to ignore it? Firstly, because using
// hooks in a class would cause a runtime error anyway.
// But why don't we care about the same kind of false positive in a functional
// component? Because even if it was a false positive, it would be confusing
// anyway. So it might make sense to rename a feature flag check in that case.
class ClassComponentWithFeatureFlag extends React.Component {
render() {
if (foo) {
useFeatureFlag();
}
}
}
`,
`
// Currently valid because we don't check for hooks in classes.
// See ClassComponentWithFeatureFlag for rationale.
// We *could* make it invalid if we don't regress that false positive.
class ClassComponentWithHook extends React.Component {
render() {
React.useState();
}
}
`,
`
// Currently valid.
// These are variations capturing the current heuristic--
// we only allow hooks in PascalCase, useFoo functions,
// or classes (due to common false positives and because they error anyway).
// We *could* make some of these invalid.
// They probably don't matter much.
(class {useHook = () => { useState(); }});
(class {useHook() { useState(); }});
(class {h = () => { useState(); }});
(class {i() { useState(); }});
`,
`
// Currently valid although we *could* consider these invalid.
// It doesn't make a lot of difference because it would crash early.
use();
_use();
useState();
_useState();
use42();
useHook();
use_hook();
React.useState();
`,
`
// Regression test for the popular "history" library
const {createHistory, useBasename} = require('history-2.1.2');
const browserHistory = useBasename(createHistory)({
basename: '/',
});
`,
`
// Regression test for some internal code.
// This shows how the "callback rule" is more relaxed,
// and doesn't kick in unless we're confident we're in
// a component or a hook.
function makeListener(instance) {
each(pixelsWithInferredEvents, pixel => {
if (useExtendedSelector(pixel.id) && extendedButton) {
foo();
}
});
}
`,
`
// Regression test for incorrectly flagged valid code.
function RegressionTest() {
const foo = cond ? a : b;
useState();
}
`,
],
invalid: [
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function ComponentWithConditionalHook() {
if (cond) {
useConditionalHook();
}
}
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function createComponent() {
return function ComponentWithConditionalHook() {
if (cond) {
useConditionalHook();
}
}
}
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHookWithConditionalHook() {
if (cond) {
useConditionalHook();
}
}
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function createHook() {
return function useHookWithConditionalHook() {
if (cond) {
useConditionalHook();
}
}
}
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function ComponentWithTernaryHook() {
cond ? useTernaryHook() : null;
}
`,
errors: [conditionalError('useTernaryHook')],
},
{
code: `
// Invalid because it's a common misunderstanding.
// We *could* make it valid but the runtime error could be confusing.
function ComponentWithHookInsideCallback() {
useEffect(() => {
useHookInsideCallback();
});
}
`,
errors: [genericError('useHookInsideCallback')],
},
{
code: `
// Invalid because it's a common misunderstanding.
// We *could* make it valid but the runtime error could be confusing.
function createComponent() {
return function ComponentWithHookInsideCallback() {
useEffect(() => {
useHookInsideCallback();
});
}
}
`,
errors: [genericError('useHookInsideCallback')],
},
{
code: `
// Invalid because it's a common misunderstanding.
// We *could* make it valid but the runtime error could be confusing.
function ComponentWithHookInsideCallback() {
function handleClick() {
useState();
}
}
`,
errors: [functionError('useState', 'handleClick')],
},
{
code: `
// Invalid because it's a common misunderstanding.
// We *could* make it valid but the runtime error could be confusing.
function createComponent() {
return function ComponentWithHookInsideCallback() {
function handleClick() {
useState();
}
}
}
`,
errors: [functionError('useState', 'handleClick')],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function ComponentWithHookInsideLoop() {
while (cond) {
useHookInsideLoop();
}
}
`,
errors: [loopError('useHookInsideLoop')],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function renderItem() {
useState();
}
function List(props) {
return props.items.map(renderItem);
}
`,
errors: [functionError('useState', 'renderItem')],
},
{
code: `
// Currently invalid because it violates the convention and removes the "taint"
// from a hook. We *could* make it valid to avoid some false positives but let's
// ensure that we don't break the "renderItem" and "normalFunctionWithConditionalHook"
// cases which must remain invalid.
function normalFunctionWithHook() {
useHookInsideNormalFunction();
}
`,
errors: [
functionError('useHookInsideNormalFunction', 'normalFunctionWithHook'),
],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function normalFunctionWithConditionalHook() {
if (cond) {
useHookInsideNormalFunction();
}
}
`,
errors: [
functionError(
'useHookInsideNormalFunction',
'normalFunctionWithConditionalHook'
),
],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHookInLoops() {
while (a) {
useHook1();
if (b) return;
useHook2();
}
while (c) {
useHook3();
if (d) return;
useHook4();
}
}
`,
errors: [
loopError('useHook1'),
loopError('useHook2'),
loopError('useHook3'),
loopError('useHook4'),
],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHookInLoops() {
while (a) {
useHook1();
if (b) continue;
useHook2();
}
}
`,
errors: [
loopError('useHook1'),
// NOTE: Small imprecision in error reporting due to caching means we
// have a conditional error here instead of a loop error. However,
// we will always get an error so this is acceptable.
conditionalError('useHook2', true),
],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useLabeledBlock() {
label: {
if (a) break label;
useHook();
}
}
`,
errors: [conditionalError('useHook')],
},
{
code: `
// Currently invalid.
// These are variations capturing the current heuristic--
// we only allow hooks in PascalCase or useFoo functions.
// We *could* make some of these valid. But before doing it,
// consider specific cases documented above that contain reasoning.
function a() { useState(); }
const whatever = function b() { useState(); };
const c = () => { useState(); };
let d = () => useState();
e = () => { useState(); };
({f: () => { useState(); }});
({g() { useState(); }});
const {j = () => { useState(); }} = {};
({k = () => { useState(); }} = {});
`,
errors: [
functionError('useState', 'a'),
functionError('useState', 'b'),
functionError('useState', 'c'),
functionError('useState', 'd'),
functionError('useState', 'e'),
functionError('useState', 'f'),
functionError('useState', 'g'),
functionError('useState', 'j'),
functionError('useState', 'k'),
],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHook() {
if (a) return;
useState();
}
`,
errors: [conditionalError('useState', true)],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHook() {
if (a) return;
if (b) {
console.log('true');
} else {
console.log('false');
}
useState();
}
`,
errors: [conditionalError('useState', true)],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHook() {
if (b) {
console.log('true');
} else {
console.log('false');
}
if (a) return;
useState();
}
`,
errors: [conditionalError('useState', true)],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHook() {
a && useHook1();
b && useHook2();
}
`,
errors: [conditionalError('useHook1'), conditionalError('useHook2')],
},
{
code: `
// Invalid because it's dangerous and might not warn otherwise.
// This *must* be invalid.
function useHook() {
try {
f();
useState();
} catch {}
}
`,
errors: [
// NOTE: This is an error since `f()` could possibly throw.
conditionalError('useState'),
],
},
],
});
function conditionalError(hook, hasPreviousFinalizer = false) {
return {
message:
`React Hook "${hook}" is called conditionally. React Hooks must be ` +
'called in the exact same order in every component render.' +
(hasPreviousFinalizer
? ' Did you accidentally call a React Hook after an early return?'
: ''),
};
}
function loopError(hook) {
return {
message:
`React Hook "${hook}" may be executed more than once. Possibly ` +
'because it is called in a loop. React Hooks must be called in the ' +
'exact same order in every component render.',
};
}
function functionError(hook, fn) {
return {
message:
`React Hook "${hook}" is called in function "${fn}" which is neither ` +
'a React function component or a custom React Hook function.',
};
}
function genericError(hook) {
return {
message:
`React Hook "${hook}" cannot be called inside a callback. React Hooks ` +
'must be called in a React function component or a custom React ' +
'Hook function.',
};
}

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
module.exports = require('./src/index');

View File

@ -0,0 +1,9 @@
'use strict';
// TODO: this doesn't make sense for an ESLint rule.
// We need to fix our build process to not create bundles for "raw" packages like this.
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/eslint-plugin-react-hooks.production.min.js');
} else {
module.exports = require('./cjs/eslint-plugin-react-hooks.development.js');
}

View File

@ -0,0 +1,29 @@
{
"name": "eslint-plugin-react-hooks",
"description": "ESLint rules for React Hooks",
"version": "0.0.0",
"repository": "facebook/react",
"files": [
"LICENSE",
"README.md",
"index.js",
"cjs"
],
"keywords": [
"eslint",
"eslint-plugin",
"eslintplugin",
"react"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/facebook/react/issues"
},
"engines": {
"node": ">=6"
},
"homepage": "https://reactjs.org/",
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
}

View File

@ -0,0 +1,545 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/* eslint-disable no-for-of-loops/no-for-of-loops */
'use strict';
/**
* Catch all identifiers that begin with "use" followed by an uppercase Latin
* character to exclude identifiers like "user".
*/
function isHookName(s) {
return /^use[A-Z0-9].*$/.test(s);
}
/**
* We consider hooks to be a hook name identifier or a member expression
* containing a hook name.
*/
function isHook(node) {
if (node.type === 'Identifier') {
return isHookName(node.name);
} else if (
node.type === 'MemberExpression' &&
!node.computed &&
isHook(node.property)
) {
// Only consider React.useFoo() to be namespace hooks for now to avoid false positives.
// We can expand this check later.
const obj = node.object;
return obj.type === 'Identifier' && obj.name === 'React';
} else {
return false;
}
}
/**
* Checks if the node is a React component name. React component names must
* always start with a non-lowercase letter. So `MyComponent` or `_MyComponent`
* are valid component names for instance.
*/
function isComponentName(node) {
if (node.type === 'Identifier') {
return !/^[a-z]/.test(node.name);
} else {
return false;
}
}
function isInsideComponentOrHook(node) {
while (node) {
const functionName = getFunctionName(node);
if (functionName) {
if (isComponentName(functionName) || isHook(functionName)) {
return true;
}
}
node = node.parent;
}
return false;
}
export default {
create(context) {
const codePathReactHooksMapStack = [];
const codePathSegmentStack = [];
return {
// Maintain code segment path stack as we traverse.
onCodePathSegmentStart: segment => codePathSegmentStack.push(segment),
onCodePathSegmentEnd: () => codePathSegmentStack.pop(),
// Maintain code path stack as we traverse.
onCodePathStart: () => codePathReactHooksMapStack.push(new Map()),
// Process our code path.
//
// Everything is ok if all React Hooks are both reachable from the initial
// segment and reachable from every final segment.
onCodePathEnd(codePath, codePathNode) {
const reactHooksMap = codePathReactHooksMapStack.pop();
if (reactHooksMap.size === 0) {
return;
}
// All of the segments which are cyclic are recorded in this set.
const cyclic = new Set();
/**
* Count the number of code paths from the start of the function to this
* segment. For example:
*
* ```js
* function MyComponent() {
* if (condition) {
* // Segment 1
* } else {
* // Segment 2
* }
* // Segment 3
* }
* ```
*
* Segments 1 and 2 have one path to the beginning of `MyComponent` and
* segment 3 has two paths to the beginning of `MyComponent` since we
* could have either taken the path of segment 1 or segment 2.
*
* Populates `cyclic` with cyclic segments.
*/
function countPathsFromStart(segment) {
const {cache} = countPathsFromStart;
let paths = cache.get(segment.id);
// If `paths` is null then we've found a cycle! Add it to `cyclic` and
// any other segments which are a part of this cycle.
if (paths === null) {
if (cyclic.has(segment.id)) {
return 0;
} else {
cyclic.add(segment.id);
for (const prevSegment of segment.prevSegments) {
countPathsFromStart(prevSegment);
}
return 0;
}
}
// We have a cached `paths`. Return it.
if (paths !== undefined) {
return paths;
}
// Compute `paths` and cache it. Guarding against cycles.
cache.set(segment.id, null);
if (segment.prevSegments.length === 0) {
paths = 1;
} else {
paths = 0;
for (const prevSegment of segment.prevSegments) {
paths += countPathsFromStart(prevSegment);
}
}
cache.set(segment.id, paths);
return paths;
}
/**
* Count the number of code paths from this segment to the end of the
* function. For example:
*
* ```js
* function MyComponent() {
* // Segment 1
* if (condition) {
* // Segment 2
* } else {
* // Segment 3
* }
* }
* ```
*
* Segments 2 and 3 have one path to the end of `MyComponent` and
* segment 1 has two paths to the end of `MyComponent` since we could
* either take the path of segment 1 or segment 2.
*
* Populates `cyclic` with cyclic segments.
*/
function countPathsToEnd(segment) {
const {cache} = countPathsToEnd;
let paths = cache.get(segment.id);
// If `paths` is null then we've found a cycle! Add it to `cyclic` and
// any other segments which are a part of this cycle.
if (paths === null) {
if (cyclic.has(segment.id)) {
return 0;
} else {
cyclic.add(segment.id);
for (const nextSegment of segment.nextSegments) {
countPathsToEnd(nextSegment);
}
return 0;
}
}
// We have a cached `paths`. Return it.
if (paths !== undefined) {
return paths;
}
// Compute `paths` and cache it. Guarding against cycles.
cache.set(segment.id, null);
if (segment.nextSegments.length === 0) {
paths = 1;
} else {
paths = 0;
for (const nextSegment of segment.nextSegments) {
paths += countPathsToEnd(nextSegment);
}
}
cache.set(segment.id, paths);
return paths;
}
/**
* Gets the shortest path length to the start of a code path.
* For example:
*
* ```js
* function MyComponent() {
* if (condition) {
* // Segment 1
* }
* // Segment 2
* }
* ```
*
* There is only one path from segment 1 to the code path start. Its
* length is one so that is the shortest path.
*
* There are two paths from segment 2 to the code path start. One
* through segment 1 with a length of two and another directly to the
* start with a length of one. The shortest path has a length of one
* so we would return that.
*/
function shortestPathLengthToStart(segment) {
const {cache} = shortestPathLengthToStart;
let length = cache.get(segment.id);
// If `length` is null then we found a cycle! Return infinity since
// the shortest path is definitely not the one where we looped.
if (length === null) {
return Infinity;
}
// We have a cached `length`. Return it.
if (length !== undefined) {
return length;
}
// Compute `length` and cache it. Guarding against cycles.
cache.set(segment.id, null);
if (segment.prevSegments.length === 0) {
length = 1;
} else {
length = Infinity;
for (const prevSegment of segment.prevSegments) {
const prevLength = shortestPathLengthToStart(prevSegment);
if (prevLength < length) {
length = prevLength;
}
}
length += 1;
}
cache.set(segment.id, length);
return length;
}
countPathsFromStart.cache = new Map();
countPathsToEnd.cache = new Map();
shortestPathLengthToStart.cache = new Map();
// Count all code paths to the end of our component/hook. Also primes
// the `countPathsToEnd` cache.
const allPathsFromStartToEnd = countPathsToEnd(codePath.initialSegment);
// Gets the function name for our code path. If the function name is
// `undefined` then we know either that we have an anonymous function
// expression or our code path is not in a function. In both cases we
// will want to error since neither are React functional components or
// hook functions.
const codePathFunctionName = getFunctionName(codePathNode);
// This is a valid code path for React hooks if we are direcly in a React
// functional component or we are in a hook function.
const isSomewhereInsideComponentOrHook = isInsideComponentOrHook(
codePathNode,
);
const isDirectlyInsideComponentOrHook = codePathFunctionName
? isComponentName(codePathFunctionName) ||
isHook(codePathFunctionName)
: false;
// Compute the earliest finalizer level using information from the
// cache. We expect all reachable final segments to have a cache entry
// after calling `visitSegment()`.
let shortestFinalPathLength = Infinity;
for (const finalSegment of codePath.finalSegments) {
if (!finalSegment.reachable) {
continue;
}
const length = shortestPathLengthToStart(finalSegment);
if (length < shortestFinalPathLength) {
shortestFinalPathLength = length;
}
}
// Make sure all React Hooks pass our lint invariants. Log warnings
// if not.
for (const [segment, reactHooks] of reactHooksMap) {
// NOTE: We could report here that the hook is not reachable, but
// that would be redundant with more general "no unreachable"
// lint rules.
if (!segment.reachable) {
continue;
}
// If there are any final segments with a shorter path to start then
// we possibly have an early return.
//
// If our segment is a final segment itself then siblings could
// possibly be early returns.
const possiblyHasEarlyReturn =
segment.nextSegments.length === 0
? shortestFinalPathLength <= shortestPathLengthToStart(segment)
: shortestFinalPathLength < shortestPathLengthToStart(segment);
// Count all the paths from the start of our code path to the end of
// our code path that go _through_ this segment. The critical piece
// of this is _through_. If we just call `countPathsToEnd(segment)`
// then we neglect that we may have gone through multiple paths to get
// to this point! Consider:
//
// ```js
// function MyComponent() {
// if (a) {
// // Segment 1
// } else {
// // Segment 2
// }
// // Segment 3
// if (b) {
// // Segment 4
// } else {
// // Segment 5
// }
// }
// ```
//
// In this component we have four code paths:
//
// 1. `a = true; b = true`
// 2. `a = true; b = false`
// 3. `a = false; b = true`
// 4. `a = false; b = false`
//
// From segment 3 there are two code paths to the end through segment
// 4 and segment 5. However, we took two paths to get here through
// segment 1 and segment 2.
//
// If we multiply the paths from start (two) by the paths to end (two)
// for segment 3 we get four. Which is our desired count.
const pathsFromStartToEnd =
countPathsFromStart(segment) * countPathsToEnd(segment);
// Is this hook a part of a cyclic segment?
const cycled = cyclic.has(segment.id);
for (const hook of reactHooks) {
// Report an error if a hook may be called more then once.
if (cycled) {
context.report(
hook,
`React Hook "${context.getSource(hook)}" may be executed ` +
'more than once. Possibly because it is called in a loop. ' +
'React Hooks must be called in the exact same order in ' +
'every component render.',
);
}
// If this is not a valid code path for React hooks then we need to
// log a warning for every hook in this code path.
//
// Pick a special message depending on the scope this hook was
// called in.
if (isDirectlyInsideComponentOrHook) {
// Report an error if a hook does not reach all finalizing code
// path segments.
//
// Special case when we think there might be an early return.
if (!cycled && pathsFromStartToEnd !== allPathsFromStartToEnd) {
context.report(
hook,
`React Hook "${context.getSource(hook)}" is called ` +
'conditionally. React Hooks must be called in the exact ' +
'same order in every component render.' +
(possiblyHasEarlyReturn
? ' Did you accidentally call a React Hook after an' +
' early return?'
: ''),
);
}
} else if (
codePathNode.parent &&
(codePathNode.parent.type === 'MethodDefinition' ||
codePathNode.parent.type === 'ClassProperty') &&
codePathNode.parent.value === codePathNode
) {
// Ignore class methods for now because they produce too many
// false positives due to feature flag checks. We're less
// sensitive to them in classes because hooks would produce
// runtime errors in classes anyway, and because a use*()
// call in a class, if it works, is unambigously *not* a hook.
} else if (codePathFunctionName) {
// Custom message if we found an invalid function name.
context.report(
hook,
`React Hook "${context.getSource(hook)}" is called in ` +
`function "${context.getSource(codePathFunctionName)}" ` +
'which is neither a React function component or a custom ' +
'React Hook function.',
);
} else if (codePathNode.type === 'Program') {
// For now, ignore if it's in top level scope.
// We could warn here but there are false positives related
// configuring libraries like `history`.
} else {
// Assume in all other cases the user called a hook in some
// random function callback. This should usually be true for
// anonymous function expressions. Hopefully this is clarifying
// enough in the common case that the incorrect message in
// uncommon cases doesn't matter.
if (isSomewhereInsideComponentOrHook) {
context.report(
hook,
`React Hook "${context.getSource(hook)}" cannot be called ` +
'inside a callback. React Hooks must be called in a ' +
'React function component or a custom React Hook function.',
);
}
}
}
}
},
// Missed opportunity...We could visit all `Identifier`s instead of all
// `CallExpression`s and check that _every use_ of a hook name is valid.
// But that gets complicated and enters type-system territory, so we're
// only being strict about hook calls for now.
CallExpression(node) {
if (isHook(node.callee)) {
// Add the hook node to a map keyed by the code path segment. We will
// do full code path analysis at the end of our code path.
const reactHooksMap = last(codePathReactHooksMapStack);
const codePathSegment = last(codePathSegmentStack);
let reactHooks = reactHooksMap.get(codePathSegment);
if (!reactHooks) {
reactHooks = [];
reactHooksMap.set(codePathSegment, reactHooks);
}
reactHooks.push(node.callee);
}
},
};
},
};
/**
* Gets tbe static name of a function AST node. For function declarations it is
* easy. For anonymous function expressions it is much harder. If you search for
* `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places
* where JS gives anonymous function expressions names. We roughly detect the
* same AST nodes with some exceptions to better fit our usecase.
*/
function getFunctionName(node) {
if (
node.type === 'FunctionDeclaration' ||
(node.type === 'FunctionExpression' && node.id)
) {
// function useHook() {}
// const whatever = function useHook() {};
//
// Function declaration or function expression names win over any
// assignment statements or other renames.
return node.id;
} else if (
node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression'
) {
if (
node.parent.type === 'VariableDeclarator' &&
node.parent.init === node
) {
// const useHook = () => {};
return node.parent.id;
} else if (
node.parent.type === 'AssignmentExpression' &&
node.parent.right === node &&
node.parent.operator === '='
) {
// useHook = () => {};
return node.parent.left;
} else if (
node.parent.type === 'Property' &&
node.parent.value === node &&
!node.parent.computed
) {
// {useHook: () => {}}
// {useHook() {}}
return node.parent.key;
// NOTE: We could also support `ClassProperty` and `MethodDefinition`
// here to be pedantic. However, hooks in a class are an anti-pattern. So
// we don't allow it to error early.
//
// class {useHook = () => {}}
// class {useHook() {}}
} else if (
node.parent.type === 'AssignmentPattern' &&
node.parent.right === node &&
!node.parent.computed
) {
// const {useHook = () => {}} = {};
// ({useHook = () => {}} = {});
//
// Kinda clowny, but we'd said we'd follow spec convention for
// `IsAnonymousFunctionDefinition()` usage.
return node.parent.left;
} else {
return undefined;
}
} else {
return undefined;
}
}
/**
* Convenience function for peeking the last item in a stack.
*/
function last(array) {
return array[array.length - 1];
}

View File

@ -0,0 +1,14 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
import RuleOfHooks from './RulesOfHooks';
export const rules = {
'rules-of-hooks': RuleOfHooks,
};

View File

@ -410,6 +410,21 @@ const bundles = [
externals: ['jest-diff'],
},
/******* ESLint Plugin for Hooks (proposal) *******/
{
label: 'eslint-plugin-react-hooks',
// TODO: it's awkward to create a bundle for this
// but if we don't, the package won't get copied.
// We also can't create just DEV bundle because
// it contains a NODE_ENV check inside.
// We should probably tweak our build process
// to allow "raw" packages that don't get bundled.
bundleTypes: [NODE_DEV, NODE_PROD],
moduleType: ISOMORPHIC,
entry: 'eslint-plugin-react-hooks',
externals: [],
},
{
label: 'scheduler-tracing',
bundleTypes: [