[eslint] Check forwardRef callbacks (#17255)
* [eslint] Check forwardRef callbacks (#17220) * [eslint] Make tests more realistic (#17220) * [eslint] Check anonymous callback of React.memo for rules-of-hooks (#17220) * [eslint] Add tests for callbacks not known to be components (#17220) * [eslint] Correct comments and add another test (#17220)
David Garner committed
Nov 17, 2019 at 13:39 UTC
a807c307c496d96e8ff79f53cf2d6203c45cf0c6
2 files changed
+174
-2
packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js
+133
@@ -141,6 +141,46 @@ const tests = {
141
return useHook1(useHook2());
142
}
143
`,
144
+ `
145
+ // Valid because hooks can be used in anonymous arrow-function arguments
146
+ // to forwardRef.
147
+ const FancyButton = React.forwardRef((props, ref) => {
148
+ useHook();
149
+ return <button {...props} ref={ref} />
150
+ });
151
+ `,
152
+ `
153
+ // Valid because hooks can be used in anonymous function arguments to
154
+ // forwardRef.
155
+ const FancyButton = React.forwardRef(function (props, ref) {
156
+ useHook();
157
+ return <button {...props} ref={ref} />
158
+ });
159
+ `,
160
+ `
161
+ // Valid because hooks can be used in anonymous function arguments to
162
+ // forwardRef.
163
+ const FancyButton = forwardRef(function (props, ref) {
164
+ useHook();
165
+ return <button {...props} ref={ref} />
166
+ });
167
+ `,
168
+ `
169
+ // Valid because hooks can be used in anonymous function arguments to
170
+ // React.memo.
171
+ const MemoizedFunction = React.memo(props => {
172
+ useHook();
173
+ return <button {...props} />
174
+ });
175
+ `,
176
+ `
177
+ // Valid because hooks can be used in anonymous function arguments to
178
+ // memo.
179
+ const MemoizedFunction = memo(function (props) {
180
+ useHook();
181
+ return <button {...props} />
182
+ });
183
+ `,
184
`
185
// Valid because classes can call functions.
186
// We don't consider these to be hooks.
@@ -262,6 +302,24 @@ const tests = {
302
});
303
}
304
`,
305
+ `
306
+ // This is valid because "use"-prefixed functions called in
307
+ // unnamed function arguments are not assumed to be hooks.
308
+ React.unknownFunction((foo, bar) => {
309
+ if (foo) {
310
+ useNotAHook(bar)
311
+ }
312
+ });
313
+ `,
314
+ `
315
+ // This is valid because "use"-prefixed functions called in
316
+ // unnamed function arguments are not assumed to be hooks.
317
+ unknownFunction(function(foo, bar) {
318
+ if (foo) {
319
+ useNotAHook(bar)
320
+ }
321
+ });
322
+ `,
323
`
324
// Regression test for incorrectly flagged valid code.
325
function RegressionTest() {
@@ -437,6 +495,32 @@ const tests = {
495
`,
496
errors: [genericError('useHookInsideCallback')],
497
},
498
+ {
499
+ code: `
500
+ // Invalid because it's a common misunderstanding.
501
+ // We *could* make it valid but the runtime error could be confusing.
502
+ const ComponentWithHookInsideCallback = React.forwardRef((props, ref) => {
503
+ useEffect(() => {
504
+ useHookInsideCallback();
505
+ });
506
+ return <button {...props} ref={ref} />
507
+ });
508
+ `,
509
+ errors: [genericError('useHookInsideCallback')],
510
+ },
511
+ {
512
+ code: `
513
+ // Invalid because it's a common misunderstanding.
514
+ // We *could* make it valid but the runtime error could be confusing.
515
+ const ComponentWithHookInsideCallback = React.memo(props => {
516
+ useEffect(() => {
517
+ useHookInsideCallback();
518
+ });
519
+ return <button {...props} />
520
+ });
521
+ `,
522
+ errors: [genericError('useHookInsideCallback')],
523
+ },
524
{
525
code: `
526
// Invalid because it's a common misunderstanding.
@@ -695,6 +779,55 @@ const tests = {
779
// conditionalError('useState'),
780
],
781
},
782
+ {
783
+ code: `
784
+ // Invalid because it's dangerous and might not warn otherwise.
785
+ // This *must* be invalid.
786
+ const FancyButton = React.forwardRef((props, ref) => {
787
+ if (props.fancy) {
788
+ useCustomHook();
789
+ }
790
+ return <button ref={ref}>{props.children}</button>;
791
+ });
792
+ `,
793
+ errors: [conditionalError('useCustomHook')],
794
+ },
795
+ {
796
+ code: `
797
+ // Invalid because it's dangerous and might not warn otherwise.
798
+ // This *must* be invalid.
799
+ const FancyButton = forwardRef(function(props, ref) {
800
+ if (props.fancy) {
801
+ useCustomHook();
802
+ }
803
+ return <button ref={ref}>{props.children}</button>;
804
+ });
805
+ `,
806
+ errors: [conditionalError('useCustomHook')],
807
+ },
808
+ {
809
+ code: `
810
+ // Invalid because it's dangerous and might not warn otherwise.
811
+ // This *must* be invalid.
812
+ const MemoizedButton = memo(function(props) {
813
+ if (props.fancy) {
814
+ useCustomHook();
815
+ }
816
+ return <button>{props.children}</button>;
817
+ });
818
+ `,
819
+ errors: [conditionalError('useCustomHook')],
820
+ },
821
+ {
822
+ code: `
823
+ // This is invalid because "use"-prefixed functions used in named
824
+ // functions are assumed to be hooks.
825
+ React.unknownFunction(function notAComponent(foo, bar) {
826
+ useProbablyAHook(bar)
827
+ });
828
+ `,
829
+ errors: [functionError('useProbablyAHook', 'notAComponent')],
830
+ },
831
{
832
code: `
833
// Invalid because it's dangerous.
packages/eslint-plugin-react-hooks/src/RulesOfHooks.js
+41
-2
@@ -54,6 +54,41 @@ function isComponentName(node) {
54
}
55
}
56
57
+function isReactFunction(node, functionName) {
58
+ return (
59
+ node.name === functionName ||
60
+ (node.type === 'MemberExpression' &&
61
+ node.object.name === 'React' &&
62
+ node.property.name === functionName)
63
+ );
64
+}
65
+
66
+/**
67
+ * Checks if the node is a callback argument of forwardRef. This render function
68
+ * should follow the rules of hooks.
69
+ */
70
+
71
+function isForwardRefCallback(node) {
72
+ return !!(
73
+ node.parent &&
74
+ node.parent.callee &&
75
+ isReactFunction(node.parent.callee, 'forwardRef')
76
+ );
77
+}
78
+
79
+/**
80
+ * Checks if the node is a callback argument of React.memo. This anonymous
81
+ * functional component should follow the rules of hooks.
82
+ */
83
+
84
+function isMemoCallback(node) {
85
+ return !!(
86
+ node.parent &&
87
+ node.parent.callee &&
88
+ isReactFunction(node.parent.callee, 'memo')
89
+ );
90
+}
91
+
92
function isInsideComponentOrHook(node) {
93
while (node) {
94
const functionName = getFunctionName(node);
@@ -62,6 +97,9 @@ function isInsideComponentOrHook(node) {
97
return true;
98
}
99
}
100
+ if (isForwardRefCallback(node) || isMemoCallback(node)) {
101
+ return true;
102
+ }
103
node = node.parent;
104
}
105
return false;
@@ -290,7 +328,8 @@ export default {
328
// `undefined` then we know either that we have an anonymous function
329
// expression or our code path is not in a function. In both cases we
330
// will want to error since neither are React function components or
293
- // hook functions.
331
+ // hook functions - unless it is an anonymous function argument to
332
+ // forwardRef or memo.
333
const codePathFunctionName = getFunctionName(codePathNode);
334
335
// This is a valid code path for React hooks if we are directly in a React
@@ -301,7 +340,7 @@ export default {
340
const isDirectlyInsideComponentOrHook = codePathFunctionName
341
? isComponentName(codePathFunctionName) ||
342
isHook(codePathFunctionName)
304
- : false;
343
+ : isForwardRefCallback(codePathNode) || isMemoCallback(codePathNode);
344
345
// Compute the earliest finalizer level using information from the
346
// cache. We expect all reachable final segments to have a cache entry