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: [