Skip to content

Commit

Permalink
Merge pull request #11 from Gelio/develop
Browse files Browse the repository at this point in the history
Release v2.0.0
  • Loading branch information
Gelio authored Mar 12, 2019
2 parents e8bab8e + 6a6cb4d commit 3cd97c7
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 3 deletions.
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## v2.0.0 (2019-03-12)

- Report violations whenever a React hook is used after an early return.

For example, the following code sample now violates the rule:

```tsx
function MyComponent({ counter }) {
if (counter > 5) {
return <div>Counter is over 5</div>;
}

useEffect(() => {
console.log('Counter is', counter);
});

return <div>{counter}</div>;
}
```

## v1.1.0 (2019-02-09)

- Allow using hooks inside React component decorators, such as `React.memo` or `React.forwardRef`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The rule is based on an [ESLint plugin for react hooks](https://github.com/faceb
- ternary expressions
- loops (`while`, `for`, `do ... while`)
- functions that themselves are not custom hooks or components
- detects using React hooks in spite of an early return

## Installation

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tslint-react-hooks",
"version": "1.1.0",
"version": "2.0.0",
"description": "TSLint rule that enforces the Rules of Hooks",
"main": "tslint-react-hooks.json",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions src/react-hooks-nesting-walker/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export const ERROR_MESSAGES = {
invalidFunctionDeclaration:
'A hook cannot be used inside of another function',
invalidFunctionExpression: 'A hook cannot be used inside of another function',
hookAfterEarlyReturn: 'A hook should not appear after a return statement',
};
8 changes: 8 additions & 0 deletions src/react-hooks-nesting-walker/find-ancestor-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Node } from 'typescript';

import { FunctionNode, isFunctionNode } from './function-node';
import { findClosestAncestorNode } from './find-closest-ancestor-node';

export function findAncestorFunction(node: Node): FunctionNode | null {
return findClosestAncestorNode(node, isFunctionNode);
}
23 changes: 23 additions & 0 deletions src/react-hooks-nesting-walker/find-closest-ancestor-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Node } from 'typescript';

import { TypeGuardPredicate } from './predicate';

/**
* Finds a closest ancestor that matches a given predicate.
*
* Ensures type safety
*/
export function findClosestAncestorNode<ParentNodeType extends Node = Node>(
startingNode: Node,
predicate: TypeGuardPredicate<Node, ParentNodeType>,
): ParentNodeType | null {
if (!startingNode.parent) {
return null;
}

if (predicate(startingNode.parent)) {
return startingNode.parent;
}

return findClosestAncestorNode(startingNode.parent, predicate);
}
20 changes: 20 additions & 0 deletions src/react-hooks-nesting-walker/function-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
FunctionDeclaration,
FunctionExpression,
ArrowFunction,
Node,
isFunctionDeclaration,
isFunctionExpression,
isArrowFunction,
} from 'typescript';

export type FunctionNode =
| FunctionDeclaration
| FunctionExpression
| ArrowFunction;

const matchers = [isFunctionDeclaration, isFunctionExpression, isArrowFunction];

export function isFunctionNode(node: Node): node is FunctionNode {
return matchers.some(matcher => matcher(node));
}
2 changes: 1 addition & 1 deletion src/react-hooks-nesting-walker/is-react-api-expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
Expression,
} from 'typescript';

export type Predicate<T> = (t: T) => boolean;
import { Predicate } from './predicate';

/**
* Tests whether an `Expression` is an identifier that matches a predicate. Accepts also property
Expand Down
3 changes: 3 additions & 0 deletions src/react-hooks-nesting-walker/predicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type Predicate<T> = (t: T) => boolean;

export type TypeGuardPredicate<T, U extends T> = (t: T) => t is U;
53 changes: 53 additions & 0 deletions src/react-hooks-nesting-walker/react-hooks-nesting-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,22 @@ import {
isSourceFile,
isClassDeclaration,
isCallExpression,
ReturnStatement,
isReturnStatement,
} from 'typescript';

import { isHookCall } from './is-hook-call';
import { ERROR_MESSAGES } from './error-messages';
import { isBinaryConditionalExpression } from './is-binary-conditional-expression';
import { isComponentOrHookIdentifier } from './is-component-or-hook-identifier';
import { isReactComponentDecorator } from './is-react-component-decorator';
import { findAncestorFunction } from './find-ancestor-function';
import { FunctionNode, isFunctionNode } from './function-node';
import { findClosestAncestorNode } from './find-closest-ancestor-node';

export class ReactHooksNestingWalker extends RuleWalker {
private functionsWithReturnStatements = new Set<FunctionNode>();

public visitCallExpression(node: CallExpression) {
if (isHookCall(node)) {
this.visitHookAncestor(node, node.parent);
Expand All @@ -31,6 +38,16 @@ export class ReactHooksNestingWalker extends RuleWalker {
super.visitCallExpression(node);
}

public visitReturnStatement(node: ReturnStatement) {
const parentFunction = findAncestorFunction(node);

if (parentFunction) {
this.functionsWithReturnStatements.add(parentFunction);
}

super.visitReturnStatement(node);
}

private visitHookAncestor(hookNode: CallExpression, ancestor: Node) {
/**
* Fail for:
Expand Down Expand Up @@ -78,6 +95,22 @@ export class ReactHooksNestingWalker extends RuleWalker {
* }
* ```
*/

if (this.functionsWithReturnStatements.has(ancestor)) {
const closestReturnStatementOrFunctionNode = findClosestAncestorNode(
hookNode,
(node): node is ReturnStatement | FunctionNode =>
isReturnStatement(node) || isFunctionNode(node),
);

if (
closestReturnStatementOrFunctionNode &&
!isReturnStatement(closestReturnStatementOrFunctionNode)
) {
this.addFailureAtNode(hookNode, ERROR_MESSAGES.hookAfterEarlyReturn);
}
}

if (ancestor.name && isComponentOrHookIdentifier(ancestor.name)) {
return;
}
Expand All @@ -102,6 +135,26 @@ export class ReactHooksNestingWalker extends RuleWalker {
* }
* ```
*/

/**
* REFACTOR: Use a shared implementation for all types of functions.
* The logic below is duplicated for function declarations.
*/
if (this.functionsWithReturnStatements.has(ancestor)) {
const closestReturnStatementOrFunctionNode = findClosestAncestorNode(
hookNode,
(node): node is ReturnStatement | FunctionNode =>
isReturnStatement(node) || isFunctionNode(node),
);

if (
closestReturnStatementOrFunctionNode &&
!isReturnStatement(closestReturnStatementOrFunctionNode)
) {
this.addFailureAtNode(hookNode, ERROR_MESSAGES.hookAfterEarlyReturn);
}
}

if (
isVariableDeclaration(ancestor.parent) &&
isIdentifier(ancestor.parent.name) &&
Expand Down
7 changes: 7 additions & 0 deletions test/not-covered-cases/use-hook-in-return.ts.lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
function MyComponentReturningHookCall() {
// Using a hook inside of a returned value should not be a rule violation,
// even if it looks silly.

return <div ref={useRef()}>Hello world</div>;
~~~~~~~~ [A hook should not appear after a return statement]
}
38 changes: 38 additions & 0 deletions test/tslint-rule/early-return.ts.lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
function MyComponent() {
const [counter, setCounter] = useState(0);

if (counter > 5) {
return <div>Counter is over 5</div>;
}

useEffect(() => {
~~~~~~~~~~~~~~~~~
console.log('Counter is', counter);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
});
~~~~ [A hook should not appear after a return statement]

return (
<div>
{counter} -
<button onClick={() => setCounter(counter + 1)}>+1</button>
</div>
);
}

function useCustomHook(param: number) {
if (param > 5) {
return 'a';
}

useEffect(() => { });
~~~~~~~~~~~~~~~~~~~~ [A hook should not appear after a return statement]
}


// =============================
// Do not report rule violations if the hook is called in the `return` statement

function useMyHook() {
return useState(true)[0];
}

0 comments on commit 3cd97c7

Please sign in to comment.