@samitouri / QOS-React / commits / ddbfe2ed50

Add ESLint rule for React Hooks

Caleb Meredith committed Oct 23, 2018 at 12:35 UTC ddbfe2ed50c7a3476ceff20f5924011ac1ad6428
8 files changed +1302
packages/eslint-plugin-react-hooks/README.md new
+48
@@ -0,0 +1,48 @@
1 +# `eslint-plugin-react-hooks`
2 +
3 +This ESLint plugin enforces the [Rules of Hooks](https://reactjs.org/docs/hooks-rules.html).
4 +
5 +It is a part of the [Hooks proposal](https://reactjs.org/docs/hooks-intro.html) for React.
6 +
7 +## Experimental Status
8 +
9 +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.
10 +
11 +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.
12 +
13 +## Installation
14 +
15 +**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.**
16 +
17 +Assuming you already have ESLint installed, run:
18 +
19 +```sh
20 +# npm
21 +npm install eslint-plugin-react-hooks@next --save-dev
22 +
23 +# yarn
24 +yarn add eslint-plugin-react-hooks@next --dev
25 +```
26 +
27 +Then add it to your ESLint configuration:
28 +
29 +```js
30 +{
31 + "plugins": [
32 + // ...
33 + "react-hooks"
34 + ],
35 + "rules": {
36 + // ...
37 + "react-hooks/rules-of-hooks": "error"
38 + }
39 +}
40 +```
41 +
42 +## Valid and Invalid Examples
43 +
44 +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.
45 +
46 +## License
47 +
48 +MIT
packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js new
+632
@@ -0,0 +1,632 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +'use strict';
9 +
10 +const ESLintTester = require('eslint').RuleTester;
11 +const ReactHooksESLintPlugin = require('eslint-plugin-react-hooks');
12 +const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['rules-of-hooks'];
13 +
14 +ESLintTester.setDefaultConfig({
15 + parser: 'babel-eslint',
16 + parserOptions: {
17 + ecmaVersion: 6,
18 + sourceType: 'module',
19 + },
20 +});
21 +
22 +const eslintTester = new ESLintTester();
23 +eslintTester.run('react-hooks', ReactHooksESLintRule, {
24 + valid: [
25 + `
26 + // Valid because components can use hooks.
27 + function ComponentWithHook() {
28 + useHook();
29 + }
30 + `,
31 + `
32 + // Valid because components can use hooks.
33 + function createComponentWithHook() {
34 + return function ComponentWithHook() {
35 + useHook();
36 + };
37 + }
38 + `,
39 + `
40 + // Valid because hooks can use hooks.
41 + function useHookWithHook() {
42 + useHook();
43 + }
44 + `,
45 + `
46 + // Valid because hooks can use hooks.
47 + function createHook() {
48 + return function useHookWithHook() {
49 + useHook();
50 + }
51 + }
52 + `,
53 + `
54 + // Valid because components can call functions.
55 + function ComponentWithNormalFunction() {
56 + doSomething();
57 + }
58 + `,
59 + `
60 + // Valid because functions can call functions.
61 + function normalFunctionWithNormalFunction() {
62 + doSomething();
63 + }
64 + `,
65 + `
66 + // Valid because functions can call functions.
67 + function normalFunctionWithConditionalFunction() {
68 + if (cond) {
69 + doSomething();
70 + }
71 + }
72 + `,
73 + `
74 + // Valid because functions can call functions.
75 + function functionThatStartsWithUseButIsntAHook() {
76 + if (cond) {
77 + userFetch();
78 + }
79 + }
80 + `,
81 + `
82 + // Valid although unconditional return doesn't make sense and would fail other rules.
83 + // We could make it invalid but it doesn't matter.
84 + function useUnreachable() {
85 + return;
86 + useHook();
87 + }
88 + `,
89 + `
90 + // Valid because hooks can call hooks.
91 + function useHook() { useState(); }
92 + const whatever = function useHook() { useState(); };
93 + const useHook1 = () => { useState(); };
94 + let useHook2 = () => useState();
95 + useHook2 = () => { useState(); };
96 + ({useHook: () => { useState(); }});
97 + ({useHook() { useState(); }});
98 + const {useHook = () => { useState(); }} = {};
99 + ({useHook = () => { useState(); }} = {});
100 + `,
101 + `
102 + // Valid because hooks can call hooks.
103 + function useHook() {
104 + useHook1();
105 + useHook2();
106 + }
107 + `,
108 + `
109 + // Valid because hooks can call hooks.
110 + function createHook() {
111 + return function useHook() {
112 + useHook1();
113 + useHook2();
114 + };
115 + }
116 + `,
117 + `
118 + // Valid because hooks can call hooks.
119 + function useHook() {
120 + useState() && a;
121 + }
122 + `,
123 + `
124 + // Valid because hooks can call hooks.
125 + function useHook() {
126 + return useHook1() + useHook2();
127 + }
128 + `,
129 + `
130 + // Valid because hooks can call hooks.
131 + function useHook() {
132 + return useHook1(useHook2());
133 + }
134 + `,
135 + `
136 + // Valid because classes can call functions.
137 + // We don't consider these to be hooks.
138 + class C {
139 + m() {
140 + this.useHook();
141 + super.useHook();
142 + }
143 + }
144 + `,
145 + `
146 + // Currently valid.
147 + // We *could* make this invalid if we want, but it creates false positives
148 + // (see the FooStore case).
149 + class C {
150 + m() {
151 + This.useHook();
152 + Super.useHook();
153 + }
154 + }
155 + `,
156 + `
157 + // Valid although we *could* consider these invalid.
158 + // But it doesn't bring much benefit since it's an immediate runtime error anyway.
159 + // So might as well allow it.
160 + Hook.use();
161 + Hook._use();
162 + Hook.useState();
163 + Hook._useState();
164 + Hook.use42();
165 + Hook.useHook();
166 + Hook.use_hook();
167 + `,
168 + `
169 + // Valid -- this is a regression test.
170 + jest.useFakeTimers();
171 + beforeEach(() => {
172 + jest.useRealTimers();
173 + })
174 + `,
175 + `
176 + // Valid because that's a false positive we've seen quite a bit.
177 + // This is a regression test.
178 + class Foo extends Component {
179 + render() {
180 + if (cond) {
181 + FooStore.useFeatureFlag();
182 + }
183 + }
184 + }
185 + `,
186 + `
187 + // Currently valid because we found this to be a common pattern
188 + // for feature flag checks in existing components.
189 + // We *could* make it invalid but that produces quite a few false positives.
190 + // Why does it make sense to ignore it? Firstly, because using
191 + // hooks in a class would cause a runtime error anyway.
192 + // But why don't we care about the same kind of false positive in a functional
193 + // component? Because even if it was a false positive, it would be confusing
194 + // anyway. So it might make sense to rename a feature flag check in that case.
195 + class ClassComponentWithFeatureFlag extends React.Component {
196 + render() {
197 + if (foo) {
198 + useFeatureFlag();
199 + }
200 + }
201 + }
202 + `,
203 + `
204 + // Currently valid because we don't check for hooks in classes.
205 + // See ClassComponentWithFeatureFlag for rationale.
206 + // We *could* make it invalid if we don't regress that false positive.
207 + class ClassComponentWithHook extends React.Component {
208 + render() {
209 + React.useState();
210 + }
211 + }
212 + `,
213 + `
214 + // Currently valid.
215 + // These are variations capturing the current heuristic--
216 + // we only allow hooks in PascalCase, useFoo functions,
217 + // or classes (due to common false positives and because they error anyway).
218 + // We *could* make some of these invalid.
219 + // They probably don't matter much.
220 + (class {useHook = () => { useState(); }});
221 + (class {useHook() { useState(); }});
222 + (class {h = () => { useState(); }});
223 + (class {i() { useState(); }});
224 + `,
225 + `
226 + // Currently valid although we *could* consider these invalid.
227 + // It doesn't make a lot of difference because it would crash early.
228 + use();
229 + _use();
230 + useState();
231 + _useState();
232 + use42();
233 + useHook();
234 + use_hook();
235 + React.useState();
236 + `,
237 + `
238 + // Regression test for the popular "history" library
239 + const {createHistory, useBasename} = require('history-2.1.2');
240 + const browserHistory = useBasename(createHistory)({
241 + basename: '/',
242 + });
243 + `,
244 + `
245 + // Regression test for some internal code.
246 + // This shows how the "callback rule" is more relaxed,
247 + // and doesn't kick in unless we're confident we're in
248 + // a component or a hook.
249 + function makeListener(instance) {
250 + each(pixelsWithInferredEvents, pixel => {
251 + if (useExtendedSelector(pixel.id) && extendedButton) {
252 + foo();
253 + }
254 + });
255 + }
256 + `,
257 + `
258 + // Regression test for incorrectly flagged valid code.
259 + function RegressionTest() {
260 + const foo = cond ? a : b;
261 + useState();
262 + }
263 + `,
264 + ],
265 + invalid: [
266 + {
267 + code: `
268 + // Invalid because it's dangerous and might not warn otherwise.
269 + // This *must* be invalid.
270 + function ComponentWithConditionalHook() {
271 + if (cond) {
272 + useConditionalHook();
273 + }
274 + }
275 + `,
276 + errors: [conditionalError('useConditionalHook')],
277 + },
278 + {
279 + code: `
280 + // Invalid because it's dangerous and might not warn otherwise.
281 + // This *must* be invalid.
282 + function createComponent() {
283 + return function ComponentWithConditionalHook() {
284 + if (cond) {
285 + useConditionalHook();
286 + }
287 + }
288 + }
289 + `,
290 + errors: [conditionalError('useConditionalHook')],
291 + },
292 + {
293 + code: `
294 + // Invalid because it's dangerous and might not warn otherwise.
295 + // This *must* be invalid.
296 + function useHookWithConditionalHook() {
297 + if (cond) {
298 + useConditionalHook();
299 + }
300 + }
301 + `,
302 + errors: [conditionalError('useConditionalHook')],
303 + },
304 + {
305 + code: `
306 + // Invalid because it's dangerous and might not warn otherwise.
307 + // This *must* be invalid.
308 + function createHook() {
309 + return function useHookWithConditionalHook() {
310 + if (cond) {
311 + useConditionalHook();
312 + }
313 + }
314 + }
315 + `,
316 + errors: [conditionalError('useConditionalHook')],
317 + },
318 + {
319 + code: `
320 + // Invalid because it's dangerous and might not warn otherwise.
321 + // This *must* be invalid.
322 + function ComponentWithTernaryHook() {
323 + cond ? useTernaryHook() : null;
324 + }
325 + `,
326 + errors: [conditionalError('useTernaryHook')],
327 + },
328 + {
329 + code: `
330 + // Invalid because it's a common misunderstanding.
331 + // We *could* make it valid but the runtime error could be confusing.
332 + function ComponentWithHookInsideCallback() {
333 + useEffect(() => {
334 + useHookInsideCallback();
335 + });
336 + }
337 + `,
338 + errors: [genericError('useHookInsideCallback')],
339 + },
340 + {
341 + code: `
342 + // Invalid because it's a common misunderstanding.
343 + // We *could* make it valid but the runtime error could be confusing.
344 + function createComponent() {
345 + return function ComponentWithHookInsideCallback() {
346 + useEffect(() => {
347 + useHookInsideCallback();
348 + });
349 + }
350 + }
351 + `,
352 + errors: [genericError('useHookInsideCallback')],
353 + },
354 + {
355 + code: `
356 + // Invalid because it's a common misunderstanding.
357 + // We *could* make it valid but the runtime error could be confusing.
358 + function ComponentWithHookInsideCallback() {
359 + function handleClick() {
360 + useState();
361 + }
362 + }
363 + `,
364 + errors: [functionError('useState', 'handleClick')],
365 + },
366 + {
367 + code: `
368 + // Invalid because it's a common misunderstanding.
369 + // We *could* make it valid but the runtime error could be confusing.
370 + function createComponent() {
371 + return function ComponentWithHookInsideCallback() {
372 + function handleClick() {
373 + useState();
374 + }
375 + }
376 + }
377 + `,
378 + errors: [functionError('useState', 'handleClick')],
379 + },
380 + {
381 + code: `
382 + // Invalid because it's dangerous and might not warn otherwise.
383 + // This *must* be invalid.
384 + function ComponentWithHookInsideLoop() {
385 + while (cond) {
386 + useHookInsideLoop();
387 + }
388 + }
389 + `,
390 + errors: [loopError('useHookInsideLoop')],
391 + },
392 + {
393 + code: `
394 + // Invalid because it's dangerous and might not warn otherwise.
395 + // This *must* be invalid.
396 + function renderItem() {
397 + useState();
398 + }
399 +
400 + function List(props) {
401 + return props.items.map(renderItem);
402 + }
403 + `,
404 + errors: [functionError('useState', 'renderItem')],
405 + },
406 + {
407 + code: `
408 + // Currently invalid because it violates the convention and removes the "taint"
409 + // from a hook. We *could* make it valid to avoid some false positives but let's
410 + // ensure that we don't break the "renderItem" and "normalFunctionWithConditionalHook"
411 + // cases which must remain invalid.
412 + function normalFunctionWithHook() {
413 + useHookInsideNormalFunction();
414 + }
415 + `,
416 + errors: [
417 + functionError('useHookInsideNormalFunction', 'normalFunctionWithHook'),
418 + ],
419 + },
420 + {
421 + code: `
422 + // Invalid because it's dangerous and might not warn otherwise.
423 + // This *must* be invalid.
424 + function normalFunctionWithConditionalHook() {
425 + if (cond) {
426 + useHookInsideNormalFunction();
427 + }
428 + }
429 + `,
430 + errors: [
431 + functionError(
432 + 'useHookInsideNormalFunction',
433 + 'normalFunctionWithConditionalHook'
434 + ),
435 + ],
436 + },
437 + {
438 + code: `
439 + // Invalid because it's dangerous and might not warn otherwise.
440 + // This *must* be invalid.
441 + function useHookInLoops() {
442 + while (a) {
443 + useHook1();
444 + if (b) return;
445 + useHook2();
446 + }
447 + while (c) {
448 + useHook3();
449 + if (d) return;
450 + useHook4();
451 + }
452 + }
453 + `,
454 + errors: [
455 + loopError('useHook1'),
456 + loopError('useHook2'),
457 + loopError('useHook3'),
458 + loopError('useHook4'),
459 + ],
460 + },
461 + {
462 + code: `
463 + // Invalid because it's dangerous and might not warn otherwise.
464 + // This *must* be invalid.
465 + function useHookInLoops() {
466 + while (a) {
467 + useHook1();
468 + if (b) continue;
469 + useHook2();
470 + }
471 + }
472 + `,
473 + errors: [
474 + loopError('useHook1'),
475 +
476 + // NOTE: Small imprecision in error reporting due to caching means we
477 + // have a conditional error here instead of a loop error. However,
478 + // we will always get an error so this is acceptable.
479 + conditionalError('useHook2', true),
480 + ],
481 + },
482 + {
483 + code: `
484 + // Invalid because it's dangerous and might not warn otherwise.
485 + // This *must* be invalid.
486 + function useLabeledBlock() {
487 + label: {
488 + if (a) break label;
489 + useHook();
490 + }
491 + }
492 + `,
493 + errors: [conditionalError('useHook')],
494 + },
495 + {
496 + code: `
497 + // Currently invalid.
498 + // These are variations capturing the current heuristic--
499 + // we only allow hooks in PascalCase or useFoo functions.
500 + // We *could* make some of these valid. But before doing it,
501 + // consider specific cases documented above that contain reasoning.
502 + function a() { useState(); }
503 + const whatever = function b() { useState(); };
504 + const c = () => { useState(); };
505 + let d = () => useState();
506 + e = () => { useState(); };
507 + ({f: () => { useState(); }});
508 + ({g() { useState(); }});
509 + const {j = () => { useState(); }} = {};
510 + ({k = () => { useState(); }} = {});
511 + `,
512 + errors: [
513 + functionError('useState', 'a'),
514 + functionError('useState', 'b'),
515 + functionError('useState', 'c'),
516 + functionError('useState', 'd'),
517 + functionError('useState', 'e'),
518 + functionError('useState', 'f'),
519 + functionError('useState', 'g'),
520 + functionError('useState', 'j'),
521 + functionError('useState', 'k'),
522 + ],
523 + },
524 + {
525 + code: `
526 + // Invalid because it's dangerous and might not warn otherwise.
527 + // This *must* be invalid.
528 + function useHook() {
529 + if (a) return;
530 + useState();
531 + }
532 + `,
533 + errors: [conditionalError('useState', true)],
534 + },
535 + {
536 + code: `
537 + // Invalid because it's dangerous and might not warn otherwise.
538 + // This *must* be invalid.
539 + function useHook() {
540 + if (a) return;
541 + if (b) {
542 + console.log('true');
543 + } else {
544 + console.log('false');
545 + }
546 + useState();
547 + }
548 + `,
549 + errors: [conditionalError('useState', true)],
550 + },
551 + {
552 + code: `
553 + // Invalid because it's dangerous and might not warn otherwise.
554 + // This *must* be invalid.
555 + function useHook() {
556 + if (b) {
557 + console.log('true');
558 + } else {
559 + console.log('false');
560 + }
561 + if (a) return;
562 + useState();
563 + }
564 + `,
565 + errors: [conditionalError('useState', true)],
566 + },
567 + {
568 + code: `
569 + // Invalid because it's dangerous and might not warn otherwise.
570 + // This *must* be invalid.
571 + function useHook() {
572 + a && useHook1();
573 + b && useHook2();
574 + }
575 + `,
576 + errors: [conditionalError('useHook1'), conditionalError('useHook2')],
577 + },
578 + {
579 + code: `
580 + // Invalid because it's dangerous and might not warn otherwise.
581 + // This *must* be invalid.
582 + function useHook() {
583 + try {
584 + f();
585 + useState();
586 + } catch {}
587 + }
588 + `,
589 + errors: [
590 + // NOTE: This is an error since `f()` could possibly throw.
591 + conditionalError('useState'),
592 + ],
593 + },
594 + ],
595 +});
596 +
597 +function conditionalError(hook, hasPreviousFinalizer = false) {
598 + return {
599 + message:
600 + `React Hook "${hook}" is called conditionally. React Hooks must be ` +
601 + 'called in the exact same order in every component render.' +
602 + (hasPreviousFinalizer
603 + ? ' Did you accidentally call a React Hook after an early return?'
604 + : ''),
605 + };
606 +}
607 +
608 +function loopError(hook) {
609 + return {
610 + message:
611 + `React Hook "${hook}" may be executed more than once. Possibly ` +
612 + 'because it is called in a loop. React Hooks must be called in the ' +
613 + 'exact same order in every component render.',
614 + };
615 +}
616 +
617 +function functionError(hook, fn) {
618 + return {
619 + message:
620 + `React Hook "${hook}" is called in function "${fn}" which is neither ` +
621 + 'a React function component or a custom React Hook function.',
622 + };
623 +}
624 +
625 +function genericError(hook) {
626 + return {
627 + message:
628 + `React Hook "${hook}" cannot be called inside a callback. React Hooks ` +
629 + 'must be called in a React function component or a custom React ' +
630 + 'Hook function.',
631 + };
632 +}
packages/eslint-plugin-react-hooks/index.js new
+10
@@ -0,0 +1,10 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +'use strict';
9 +
10 +module.exports = require('./src/index');
packages/eslint-plugin-react-hooks/npm/index.js new
+9
@@ -0,0 +1,9 @@
1 +'use strict';
2 +
3 +// TODO: this doesn't make sense for an ESLint rule.
4 +// We need to fix our build process to not create bundles for "raw" packages like this.
5 +if (process.env.NODE_ENV === 'production') {
6 + module.exports = require('./cjs/eslint-plugin-react-hooks.production.min.js');
7 +} else {
8 + module.exports = require('./cjs/eslint-plugin-react-hooks.development.js');
9 +}
packages/eslint-plugin-react-hooks/package.json new
+29
@@ -0,0 +1,29 @@
1 +{
2 + "name": "eslint-plugin-react-hooks",
3 + "description": "ESLint rules for React Hooks",
4 + "version": "0.0.0",
5 + "repository": "facebook/react",
6 + "files": [
7 + "LICENSE",
8 + "README.md",
9 + "index.js",
10 + "cjs"
11 + ],
12 + "keywords": [
13 + "eslint",
14 + "eslint-plugin",
15 + "eslintplugin",
16 + "react"
17 + ],
18 + "license": "MIT",
19 + "bugs": {
20 + "url": "https://github.com/facebook/react/issues"
21 + },
22 + "engines": {
23 + "node": ">=6"
24 + },
25 + "homepage": "https://reactjs.org/",
26 + "peerDependencies": {
27 + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0"
28 + }
29 +}
packages/eslint-plugin-react-hooks/src/RulesOfHooks.js new
+545
@@ -0,0 +1,545 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +/* eslint-disable no-for-of-loops/no-for-of-loops */
9 +
10 +'use strict';
11 +
12 +/**
13 + * Catch all identifiers that begin with "use" followed by an uppercase Latin
14 + * character to exclude identifiers like "user".
15 + */
16 +
17 +function isHookName(s) {
18 + return /^use[A-Z0-9].*$/.test(s);
19 +}
20 +
21 +/**
22 + * We consider hooks to be a hook name identifier or a member expression
23 + * containing a hook name.
24 + */
25 +
26 +function isHook(node) {
27 + if (node.type === 'Identifier') {
28 + return isHookName(node.name);
29 + } else if (
30 + node.type === 'MemberExpression' &&
31 + !node.computed &&
32 + isHook(node.property)
33 + ) {
34 + // Only consider React.useFoo() to be namespace hooks for now to avoid false positives.
35 + // We can expand this check later.
36 + const obj = node.object;
37 + return obj.type === 'Identifier' && obj.name === 'React';
38 + } else {
39 + return false;
40 + }
41 +}
42 +
43 +/**
44 + * Checks if the node is a React component name. React component names must
45 + * always start with a non-lowercase letter. So `MyComponent` or `_MyComponent`
46 + * are valid component names for instance.
47 + */
48 +
49 +function isComponentName(node) {
50 + if (node.type === 'Identifier') {
51 + return !/^[a-z]/.test(node.name);
52 + } else {
53 + return false;
54 + }
55 +}
56 +
57 +function isInsideComponentOrHook(node) {
58 + while (node) {
59 + const functionName = getFunctionName(node);
60 + if (functionName) {
61 + if (isComponentName(functionName) || isHook(functionName)) {
62 + return true;
63 + }
64 + }
65 + node = node.parent;
66 + }
67 + return false;
68 +}
69 +
70 +export default {
71 + create(context) {
72 + const codePathReactHooksMapStack = [];
73 + const codePathSegmentStack = [];
74 + return {
75 + // Maintain code segment path stack as we traverse.
76 + onCodePathSegmentStart: segment => codePathSegmentStack.push(segment),
77 + onCodePathSegmentEnd: () => codePathSegmentStack.pop(),
78 +
79 + // Maintain code path stack as we traverse.
80 + onCodePathStart: () => codePathReactHooksMapStack.push(new Map()),
81 +
82 + // Process our code path.
83 + //
84 + // Everything is ok if all React Hooks are both reachable from the initial
85 + // segment and reachable from every final segment.
86 + onCodePathEnd(codePath, codePathNode) {
87 + const reactHooksMap = codePathReactHooksMapStack.pop();
88 + if (reactHooksMap.size === 0) {
89 + return;
90 + }
91 +
92 + // All of the segments which are cyclic are recorded in this set.
93 + const cyclic = new Set();
94 +
95 + /**
96 + * Count the number of code paths from the start of the function to this
97 + * segment. For example:
98 + *
99 + * ```js
100 + * function MyComponent() {
101 + * if (condition) {
102 + * // Segment 1
103 + * } else {
104 + * // Segment 2
105 + * }
106 + * // Segment 3
107 + * }
108 + * ```
109 + *
110 + * Segments 1 and 2 have one path to the beginning of `MyComponent` and
111 + * segment 3 has two paths to the beginning of `MyComponent` since we
112 + * could have either taken the path of segment 1 or segment 2.
113 + *
114 + * Populates `cyclic` with cyclic segments.
115 + */
116 +
117 + function countPathsFromStart(segment) {
118 + const {cache} = countPathsFromStart;
119 + let paths = cache.get(segment.id);
120 +
121 + // If `paths` is null then we've found a cycle! Add it to `cyclic` and
122 + // any other segments which are a part of this cycle.
123 + if (paths === null) {
124 + if (cyclic.has(segment.id)) {
125 + return 0;
126 + } else {
127 + cyclic.add(segment.id);
128 + for (const prevSegment of segment.prevSegments) {
129 + countPathsFromStart(prevSegment);
130 + }
131 + return 0;
132 + }
133 + }
134 +
135 + // We have a cached `paths`. Return it.
136 + if (paths !== undefined) {
137 + return paths;
138 + }
139 +
140 + // Compute `paths` and cache it. Guarding against cycles.
141 + cache.set(segment.id, null);
142 + if (segment.prevSegments.length === 0) {
143 + paths = 1;
144 + } else {
145 + paths = 0;
146 + for (const prevSegment of segment.prevSegments) {
147 + paths += countPathsFromStart(prevSegment);
148 + }
149 + }
150 + cache.set(segment.id, paths);
151 +
152 + return paths;
153 + }
154 +
155 + /**
156 + * Count the number of code paths from this segment to the end of the
157 + * function. For example:
158 + *
159 + * ```js
160 + * function MyComponent() {
161 + * // Segment 1
162 + * if (condition) {
163 + * // Segment 2
164 + * } else {
165 + * // Segment 3
166 + * }
167 + * }
168 + * ```
169 + *
170 + * Segments 2 and 3 have one path to the end of `MyComponent` and
171 + * segment 1 has two paths to the end of `MyComponent` since we could
172 + * either take the path of segment 1 or segment 2.
173 + *
174 + * Populates `cyclic` with cyclic segments.
175 + */
176 +
177 + function countPathsToEnd(segment) {
178 + const {cache} = countPathsToEnd;
179 + let paths = cache.get(segment.id);
180 +
181 + // If `paths` is null then we've found a cycle! Add it to `cyclic` and
182 + // any other segments which are a part of this cycle.
183 + if (paths === null) {
184 + if (cyclic.has(segment.id)) {
185 + return 0;
186 + } else {
187 + cyclic.add(segment.id);
188 + for (const nextSegment of segment.nextSegments) {
189 + countPathsToEnd(nextSegment);
190 + }
191 + return 0;
192 + }
193 + }
194 +
195 + // We have a cached `paths`. Return it.
196 + if (paths !== undefined) {
197 + return paths;
198 + }
199 +
200 + // Compute `paths` and cache it. Guarding against cycles.
201 + cache.set(segment.id, null);
202 + if (segment.nextSegments.length === 0) {
203 + paths = 1;
204 + } else {
205 + paths = 0;
206 + for (const nextSegment of segment.nextSegments) {
207 + paths += countPathsToEnd(nextSegment);
208 + }
209 + }
210 + cache.set(segment.id, paths);
211 +
212 + return paths;
213 + }
214 +
215 + /**
216 + * Gets the shortest path length to the start of a code path.
217 + * For example:
218 + *
219 + * ```js
220 + * function MyComponent() {
221 + * if (condition) {
222 + * // Segment 1
223 + * }
224 + * // Segment 2
225 + * }
226 + * ```
227 + *
228 + * There is only one path from segment 1 to the code path start. Its
229 + * length is one so that is the shortest path.
230 + *
231 + * There are two paths from segment 2 to the code path start. One
232 + * through segment 1 with a length of two and another directly to the
233 + * start with a length of one. The shortest path has a length of one
234 + * so we would return that.
235 + */
236 +
237 + function shortestPathLengthToStart(segment) {
238 + const {cache} = shortestPathLengthToStart;
239 + let length = cache.get(segment.id);
240 +
241 + // If `length` is null then we found a cycle! Return infinity since
242 + // the shortest path is definitely not the one where we looped.
243 + if (length === null) {
244 + return Infinity;
245 + }
246 +
247 + // We have a cached `length`. Return it.
248 + if (length !== undefined) {
249 + return length;
250 + }
251 +
252 + // Compute `length` and cache it. Guarding against cycles.
253 + cache.set(segment.id, null);
254 + if (segment.prevSegments.length === 0) {
255 + length = 1;
256 + } else {
257 + length = Infinity;
258 + for (const prevSegment of segment.prevSegments) {
259 + const prevLength = shortestPathLengthToStart(prevSegment);
260 + if (prevLength < length) {
261 + length = prevLength;
262 + }
263 + }
264 + length += 1;
265 + }
266 + cache.set(segment.id, length);
267 + return length;
268 + }
269 +
270 + countPathsFromStart.cache = new Map();
271 + countPathsToEnd.cache = new Map();
272 + shortestPathLengthToStart.cache = new Map();
273 +
274 + // Count all code paths to the end of our component/hook. Also primes
275 + // the `countPathsToEnd` cache.
276 + const allPathsFromStartToEnd = countPathsToEnd(codePath.initialSegment);
277 +
278 + // Gets the function name for our code path. If the function name is
279 + // `undefined` then we know either that we have an anonymous function
280 + // expression or our code path is not in a function. In both cases we
281 + // will want to error since neither are React functional components or
282 + // hook functions.
283 + const codePathFunctionName = getFunctionName(codePathNode);
284 +
285 + // This is a valid code path for React hooks if we are direcly in a React
286 + // functional component or we are in a hook function.
287 + const isSomewhereInsideComponentOrHook = isInsideComponentOrHook(
288 + codePathNode,
289 + );
290 + const isDirectlyInsideComponentOrHook = codePathFunctionName
291 + ? isComponentName(codePathFunctionName) ||
292 + isHook(codePathFunctionName)
293 + : false;
294 +
295 + // Compute the earliest finalizer level using information from the
296 + // cache. We expect all reachable final segments to have a cache entry
297 + // after calling `visitSegment()`.
298 + let shortestFinalPathLength = Infinity;
299 + for (const finalSegment of codePath.finalSegments) {
300 + if (!finalSegment.reachable) {
301 + continue;
302 + }
303 + const length = shortestPathLengthToStart(finalSegment);
304 + if (length < shortestFinalPathLength) {
305 + shortestFinalPathLength = length;
306 + }
307 + }
308 +
309 + // Make sure all React Hooks pass our lint invariants. Log warnings
310 + // if not.
311 + for (const [segment, reactHooks] of reactHooksMap) {
312 + // NOTE: We could report here that the hook is not reachable, but
313 + // that would be redundant with more general "no unreachable"
314 + // lint rules.
315 + if (!segment.reachable) {
316 + continue;
317 + }
318 +
319 + // If there are any final segments with a shorter path to start then
320 + // we possibly have an early return.
321 + //
322 + // If our segment is a final segment itself then siblings could
323 + // possibly be early returns.
324 + const possiblyHasEarlyReturn =
325 + segment.nextSegments.length === 0
326 + ? shortestFinalPathLength <= shortestPathLengthToStart(segment)
327 + : shortestFinalPathLength < shortestPathLengthToStart(segment);
328 +
329 + // Count all the paths from the start of our code path to the end of
330 + // our code path that go _through_ this segment. The critical piece
331 + // of this is _through_. If we just call `countPathsToEnd(segment)`
332 + // then we neglect that we may have gone through multiple paths to get
333 + // to this point! Consider:
334 + //
335 + // ```js
336 + // function MyComponent() {
337 + // if (a) {
338 + // // Segment 1
339 + // } else {
340 + // // Segment 2
341 + // }
342 + // // Segment 3
343 + // if (b) {
344 + // // Segment 4
345 + // } else {
346 + // // Segment 5
347 + // }
348 + // }
349 + // ```
350 + //
351 + // In this component we have four code paths:
352 + //
353 + // 1. `a = true; b = true`
354 + // 2. `a = true; b = false`
355 + // 3. `a = false; b = true`
356 + // 4. `a = false; b = false`
357 + //
358 + // From segment 3 there are two code paths to the end through segment
359 + // 4 and segment 5. However, we took two paths to get here through
360 + // segment 1 and segment 2.
361 + //
362 + // If we multiply the paths from start (two) by the paths to end (two)
363 + // for segment 3 we get four. Which is our desired count.
364 + const pathsFromStartToEnd =
365 + countPathsFromStart(segment) * countPathsToEnd(segment);
366 +
367 + // Is this hook a part of a cyclic segment?
368 + const cycled = cyclic.has(segment.id);
369 +
370 + for (const hook of reactHooks) {
371 + // Report an error if a hook may be called more then once.
372 + if (cycled) {
373 + context.report(
374 + hook,
375 + `React Hook "${context.getSource(hook)}" may be executed ` +
376 + 'more than once. Possibly because it is called in a loop. ' +
377 + 'React Hooks must be called in the exact same order in ' +
378 + 'every component render.',
379 + );
380 + }
381 +
382 + // If this is not a valid code path for React hooks then we need to
383 + // log a warning for every hook in this code path.
384 + //
385 + // Pick a special message depending on the scope this hook was
386 + // called in.
387 + if (isDirectlyInsideComponentOrHook) {
388 + // Report an error if a hook does not reach all finalizing code
389 + // path segments.
390 + //
391 + // Special case when we think there might be an early return.
392 + if (!cycled && pathsFromStartToEnd !== allPathsFromStartToEnd) {
393 + context.report(
394 + hook,
395 + `React Hook "${context.getSource(hook)}" is called ` +
396 + 'conditionally. React Hooks must be called in the exact ' +
397 + 'same order in every component render.' +
398 + (possiblyHasEarlyReturn
399 + ? ' Did you accidentally call a React Hook after an' +
400 + ' early return?'
401 + : ''),
402 + );
403 + }
404 + } else if (
405 + codePathNode.parent &&
406 + (codePathNode.parent.type === 'MethodDefinition' ||
407 + codePathNode.parent.type === 'ClassProperty') &&
408 + codePathNode.parent.value === codePathNode
409 + ) {
410 + // Ignore class methods for now because they produce too many
411 + // false positives due to feature flag checks. We're less
412 + // sensitive to them in classes because hooks would produce
413 + // runtime errors in classes anyway, and because a use*()
414 + // call in a class, if it works, is unambigously *not* a hook.
415 + } else if (codePathFunctionName) {
416 + // Custom message if we found an invalid function name.
417 + context.report(
418 + hook,
419 + `React Hook "${context.getSource(hook)}" is called in ` +
420 + `function "${context.getSource(codePathFunctionName)}" ` +
421 + 'which is neither a React function component or a custom ' +
422 + 'React Hook function.',
423 + );
424 + } else if (codePathNode.type === 'Program') {
425 + // For now, ignore if it's in top level scope.
426 + // We could warn here but there are false positives related
427 + // configuring libraries like `history`.
428 + } else {
429 + // Assume in all other cases the user called a hook in some
430 + // random function callback. This should usually be true for
431 + // anonymous function expressions. Hopefully this is clarifying
432 + // enough in the common case that the incorrect message in
433 + // uncommon cases doesn't matter.
434 + if (isSomewhereInsideComponentOrHook) {
435 + context.report(
436 + hook,
437 + `React Hook "${context.getSource(hook)}" cannot be called ` +
438 + 'inside a callback. React Hooks must be called in a ' +
439 + 'React function component or a custom React Hook function.',
440 + );
441 + }
442 + }
443 + }
444 + }
445 + },
446 +
447 + // Missed opportunity...We could visit all `Identifier`s instead of all
448 + // `CallExpression`s and check that _every use_ of a hook name is valid.
449 + // But that gets complicated and enters type-system territory, so we're
450 + // only being strict about hook calls for now.
451 + CallExpression(node) {
452 + if (isHook(node.callee)) {
453 + // Add the hook node to a map keyed by the code path segment. We will
454 + // do full code path analysis at the end of our code path.
455 + const reactHooksMap = last(codePathReactHooksMapStack);
456 + const codePathSegment = last(codePathSegmentStack);
457 + let reactHooks = reactHooksMap.get(codePathSegment);
458 + if (!reactHooks) {
459 + reactHooks = [];
460 + reactHooksMap.set(codePathSegment, reactHooks);
461 + }
462 + reactHooks.push(node.callee);
463 + }
464 + },
465 + };
466 + },
467 +};
468 +
469 +/**
470 + * Gets tbe static name of a function AST node. For function declarations it is
471 + * easy. For anonymous function expressions it is much harder. If you search for
472 + * `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places
473 + * where JS gives anonymous function expressions names. We roughly detect the
474 + * same AST nodes with some exceptions to better fit our usecase.
475 + */
476 +
477 +function getFunctionName(node) {
478 + if (
479 + node.type === 'FunctionDeclaration' ||
480 + (node.type === 'FunctionExpression' && node.id)
481 + ) {
482 + // function useHook() {}
483 + // const whatever = function useHook() {};
484 + //
485 + // Function declaration or function expression names win over any
486 + // assignment statements or other renames.
487 + return node.id;
488 + } else if (
489 + node.type === 'FunctionExpression' ||
490 + node.type === 'ArrowFunctionExpression'
491 + ) {
492 + if (
493 + node.parent.type === 'VariableDeclarator' &&
494 + node.parent.init === node
495 + ) {
496 + // const useHook = () => {};
497 + return node.parent.id;
498 + } else if (
499 + node.parent.type === 'AssignmentExpression' &&
500 + node.parent.right === node &&
501 + node.parent.operator === '='
502 + ) {
503 + // useHook = () => {};
504 + return node.parent.left;
505 + } else if (
506 + node.parent.type === 'Property' &&
507 + node.parent.value === node &&
508 + !node.parent.computed
509 + ) {
510 + // {useHook: () => {}}
511 + // {useHook() {}}
512 + return node.parent.key;
513 +
514 + // NOTE: We could also support `ClassProperty` and `MethodDefinition`
515 + // here to be pedantic. However, hooks in a class are an anti-pattern. So
516 + // we don't allow it to error early.
517 + //
518 + // class {useHook = () => {}}
519 + // class {useHook() {}}
520 + } else if (
521 + node.parent.type === 'AssignmentPattern' &&
522 + node.parent.right === node &&
523 + !node.parent.computed
524 + ) {
525 + // const {useHook = () => {}} = {};
526 + // ({useHook = () => {}} = {});
527 + //
528 + // Kinda clowny, but we'd said we'd follow spec convention for
529 + // `IsAnonymousFunctionDefinition()` usage.
530 + return node.parent.left;
531 + } else {
532 + return undefined;
533 + }
534 + } else {
535 + return undefined;
536 + }
537 +}
538 +
539 +/**
540 + * Convenience function for peeking the last item in a stack.
541 + */
542 +
543 +function last(array) {
544 + return array[array.length - 1];
545 +}
packages/eslint-plugin-react-hooks/src/index.js new
+14
@@ -0,0 +1,14 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +'use strict';
9 +
10 +import RuleOfHooks from './RulesOfHooks';
11 +
12 +export const rules = {
13 + 'rules-of-hooks': RuleOfHooks,
14 +};
scripts/rollup/bundles.js
+15
@@ -410,6 +410,21 @@ const bundles = [
410 externals: ['jest-diff'],
411 },
412
413 + /******* ESLint Plugin for Hooks (proposal) *******/
414 + {
415 + label: 'eslint-plugin-react-hooks',
416 + // TODO: it's awkward to create a bundle for this
417 + // but if we don't, the package won't get copied.
418 + // We also can't create just DEV bundle because
419 + // it contains a NODE_ENV check inside.
420 + // We should probably tweak our build process
421 + // to allow "raw" packages that don't get bundled.
422 + bundleTypes: [NODE_DEV, NODE_PROD],
423 + moduleType: ISOMORPHIC,
424 + entry: 'eslint-plugin-react-hooks',
425 + externals: [],
426 + },
427 +
428 {
429 label: 'scheduler-tracing',
430 bundleTypes: [