diff --git a/fixtures/eslint/.eslintrc.json b/fixtures/eslint/.eslintrc.json index 982d6c8eb65f8..0c97574cfee83 100644 --- a/fixtures/eslint/.eslintrc.json +++ b/fixtures/eslint/.eslintrc.json @@ -9,6 +9,7 @@ }, "plugins": ["react-hooks"], "rules": { - "react-hooks/rules-of-hooks": 2 + "react-hooks/rules-of-hooks": 2, + "react-hooks/reactive-deps": 2 } } diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleReactiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleReactiveDeps-test.js new file mode 100644 index 0000000000000..e374059b01d2f --- /dev/null +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleReactiveDeps-test.js @@ -0,0 +1,639 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const ESLintTester = require('eslint').RuleTester; +const ReactHooksESLintPlugin = require('eslint-plugin-react-hooks'); +const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['reactive-deps']; + +ESLintTester.setDefaultConfig({ + parser: 'babel-eslint', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, +}); + +const eslintTester = new ESLintTester(); +eslintTester.run('react-hooks', ReactHooksESLintRule, { + valid: [ + ` + // TODO: we don't care about hooks outside of components. + const local = 42; + useEffect(() => { + console.log(local); + }, []); + `, + ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }); + } + `, + ` + function MyComponent() { + useEffect(() => { + const local = 42; + console.log(local); + }, []); + } + `, + ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + ` + // TODO: we don't care about hooks outside of components. + const local1 = 42; + { + const local2 = 42; + useEffect(() => { + console.log(local1); + console.log(local2); + }, []); + } + `, + ` + function MyComponent() { + const local1 = 42; + { + const local2 = 42; + useEffect(() => { + console.log(local1); + console.log(local2); + }); + } + } + `, + ` + function MyComponent() { + const local1 = 42; + { + const local2 = 42; + useEffect(() => { + console.log(local1); + console.log(local2); + }, [local1, local2]); + } + } + `, + ` + function MyComponent() { + const local1 = 42; + function MyNestedComponent() { + const local2 = 42; + useEffect(() => { + console.log(local1); + console.log(local2); + }, [local2]); + } + } + `, + ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + console.log(local); + }, [local]); + } + `, + ` + function MyComponent() { + useEffect(() => { + console.log(unresolved); + }, []); + } + `, + ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, [,,,local,,,]); + } + `, + ` + // Regression test + function MyComponent({ foo }) { + useEffect(() => { + console.log(foo.length); + }, [foo]); + } + `, + ` + // Regression test + function MyComponent({ foo }) { + useEffect(() => { + console.log(foo.length); + console.log(foo.slice(0)); + }, [foo]); + } + `, + ` + // Regression test + function MyComponent({ history }) { + useEffect(() => { + return history.listen(); + }, [history]); + } + `, + ` + // TODO: we might want to forbid dot-access in deps. + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, [props.foo]); + } + `, + ` + // TODO: we might want to forbid dot-access in deps. + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + }, [props.foo, props.bar]); + } + `, + ` + // TODO: we might want to forbid dot-access in deps. + function MyComponent(props) { + const local = 42; + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + console.log(local); + }, [props.foo, props.bar, local]); + } + `, + { + code: ` + // TODO: we might want to warn "props.foo" + // is extraneous because we already have "props". + function MyComponent(props) { + const local = 42; + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + }, [props, props.foo]); + } + `, + }, + { + code: ` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }); + } + `, + options: [{additionalHooks: 'useCustomEffect'}], + }, + { + code: ` + // TODO: we might want to forbid dot-access in deps. + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, [props.foo]); + } + `, + options: [{additionalHooks: 'useCustomEffect'}], + }, + ], + invalid: [ + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, []); + } + `, + errors: [missingError('local')], + }, + { + code: ` + // Regression test + function MyComponent() { + const local = 42; + useEffect(() => { + if (true) { + console.log(local); + } + }, []); + } + `, + errors: [missingError('local')], + }, + { + code: ` + // Regression test + function MyComponent() { + const local = 42; + useEffect(() => { + try { + console.log(local); + } finally {} + }, []); + } + `, + errors: [missingError('local')], + }, + { + code: ` + // Regression test + function MyComponent() { + const local = 42; + useEffect(() => { + function inner() { + console.log(local); + } + inner(); + }, []); + } + `, + errors: [missingError('local')], + }, + { + code: ` + function MyComponent() { + const local1 = 42; + { + const local2 = 42; + useEffect(() => { + console.log(local1); + console.log(local2); + }, []); + } + } + `, + errors: [missingError('local1'), missingError('local2')], + }, + { + code: ` + function MyComponent() { + const local1 = 42; + const local2 = 42; + useEffect(() => { + console.log(local1); + console.log(local2); + }, [local1]); + } + `, + errors: [missingError('local2')], + }, + { + code: ` + function MyComponent() { + const local1 = 42; + const local2 = 42; + useEffect(() => { + console.log(local1); + }, [local1, local2]); + } + `, + errors: [extraError('local2')], + }, + { + code: ` + function MyComponent() { + const local1 = 42; + function MyNestedComponent() { + const local2 = 42; + useEffect(() => { + console.log(local1); + console.log(local2); + }, [local1]); + } + } + `, + errors: [missingError('local2'), extraError('local1')], + }, + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + console.log(local); + }, []); + } + `, + errors: [missingError('local'), missingError('local')], + }, + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + console.log(local); + }, [local, local]); + } + `, + errors: [duplicateError('local')], + }, + { + code: ` + function MyComponent() { + useEffect(() => {}, [local]); + } + `, + errors: [extraError('local')], + }, + { + code: ` + function MyComponent() { + const dependencies = []; + useEffect(() => {}, dependencies); + } + `, + errors: [ + 'React Hook useEffect has a second argument which is not an array ' + + "literal. This means we can't statically verify whether you've " + + 'passed the correct dependencies.', + ], + }, + { + code: ` + function MyComponent() { + const local = 42; + const dependencies = [local]; + useEffect(() => { + console.log(local); + }, dependencies); + } + `, + errors: [ + missingError('local'), + 'React Hook useEffect has a second argument which is not an array ' + + "literal. This means we can't statically verify whether you've " + + 'passed the correct dependencies.', + ], + }, + { + code: ` + function MyComponent() { + const local = 42; + const dependencies = [local]; + useEffect(() => { + console.log(local); + }, [...dependencies]); + } + `, + errors: [ + missingError('local'), + 'React Hook useEffect has a spread element in its dependency list. ' + + "This means we can't statically verify whether you've passed the " + + 'correct dependencies.', + ], + }, + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, [local, ...dependencies]); + } + `, + errors: [ + 'React Hook useEffect has a spread element in its dependency list. ' + + "This means we can't statically verify whether you've passed the " + + 'correct dependencies.', + ], + }, + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, [computeCacheKey(local)]); + } + `, + errors: [ + missingError('local'), + "Unsupported expression in React Hook useEffect's dependency list. " + + 'Currently only simple variables are supported.', + ], + }, + { + code: ` + // TODO: need to think more about this case. + function MyComponent() { + const local = {id: 42}; + useEffect(() => { + console.log(local); + }, [local.id]); + } + `, + errors: [missingError('local'), extraError('local.id')], + }, + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, [local, local]); + } + `, + errors: [duplicateError('local')], + }, + { + code: ` + function MyComponent() { + const local1 = 42; + useEffect(() => { + const local1 = 42; + console.log(local1); + }, [local1]); + } + `, + errors: [extraError('local1')], + }, + { + code: ` + function MyComponent() { + const local1 = 42; + useEffect(() => {}, [local1]); + } + `, + errors: [extraError('local1')], + }, + { + code: ` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, []); + } + `, + errors: [missingError('props.foo')], + }, + { + code: ` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + }, []); + } + `, + errors: [missingError('props.foo'), missingError('props.bar')], + }, + { + code: ` + function MyComponent(props) { + const local = 42; + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + console.log(local); + }, []); + } + `, + errors: [ + missingError('props.foo'), + missingError('props.bar'), + missingError('local'), + ], + }, + { + code: ` + function MyComponent(props) { + const local = 42; + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + console.log(local); + }, [props]); + } + `, + errors: [missingError('local')], + }, + { + code: ` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, []); + useCallback(() => { + console.log(props.foo); + }, []); + useMemo(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCallback(() => { + console.log(props.foo); + }, []); + React.useMemo(() => { + console.log(props.foo); + }, []); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); + } + `, + errors: [ + missingError('props.foo', 'useEffect'), + missingError('props.foo', 'useCallback'), + missingError('props.foo', 'useMemo'), + missingError('props.foo', 'React.useEffect'), + missingError('props.foo', 'React.useCallback'), + missingError('props.foo', 'React.useMemo'), + ], + }, + { + code: ` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, []); + useEffect(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCustomEffect(() => { + console.log(props.foo); + }, []); + } + `, + options: [{additionalHooks: 'useCustomEffect'}], + errors: [ + missingError('props.foo', 'useCustomEffect'), + missingError('props.foo', 'useEffect'), + missingError('props.foo', 'React.useEffect'), + ], + }, + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, [a ? local : b]); + } + `, + errors: [ + missingError('local'), + "Unsupported expression in React Hook useEffect's dependency list. " + + 'Currently only simple variables are supported.', + ], + }, + { + code: ` + function MyComponent() { + const local = 42; + useEffect(() => { + console.log(local); + }, [a && local]); + } + `, + errors: [ + missingError('local'), + "Unsupported expression in React Hook useEffect's dependency list. " + + 'Currently only simple variables are supported.', + ], + }, + ], +}); + +function missingError(dependency, hook = 'useEffect') { + return ( + `React Hook ${hook} references "${dependency}", but it was not listed in ` + + `the hook dependencies argument. This means if "${dependency}" changes ` + + `then ${hook} won't be able to update.` + ); +} + +function extraError(dependency) { + return ( + `React Hook useEffect has an extra dependency "${dependency}" which is ` + + `not used in its callback. Removing this dependency may mean the hook ` + + `needs to execute fewer times.` + ); +} + +function duplicateError(dependency) { + return `Duplicate value in React Hook useEffect's dependency list for "${dependency}".`; +} diff --git a/packages/eslint-plugin-react-hooks/src/ReactiveDeps.js b/packages/eslint-plugin-react-hooks/src/ReactiveDeps.js new file mode 100644 index 0000000000000..6da40cce853cd --- /dev/null +++ b/packages/eslint-plugin-react-hooks/src/ReactiveDeps.js @@ -0,0 +1,441 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +'use strict'; + +/** + * Is this node a reactive React Hook? This includes: + * + * - `useEffect()` + * - `useCallback()` + * - `useMemo()` + * + * TODO: implement autofix. + * + * Also supports `React` namespacing. e.g. `React.useEffect()`. + * + * NOTE: This is a very naive check. We don't look to make sure these reactive + * hooks are imported correctly. + */ +function isReactiveHook(node, options) { + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'React' && + node.property.type === 'Identifier' && + !node.computed + ) { + return isReactiveHook(node.property); + } else if ( + node.type === 'Identifier' && + (node.name === 'useEffect' || + node.name === 'useLayoutEffect' || + node.name === 'useCallback' || + node.name === 'useMemo') + ) { + return true; + } else if (options && options.additionalHooks) { + // Allow the user to provide a regular expression which enables the lint to + // target custom reactive hooks. + let name; + try { + name = getAdditionalHookName(node); + } catch (error) { + if (/Unsupported node type/.test(error.message)) { + return false; + } else { + throw error; + } + } + return options.additionalHooks.test(name); + } else { + return false; + } +} + +/** + * Create a name we will test against our `additionalHooks` regular expression. + */ +function getAdditionalHookName(node) { + if (node.type === 'Identifier') { + return node.name; + } else if (node.type === 'MemberExpression' && !node.computed) { + const object = getAdditionalHookName(node.object); + const property = getAdditionalHookName(node.property); + return `${object}.${property}`; + } else { + throw new Error(`Unsupported node type: ${node.type}`); + } +} + +/** + * Is this node the callback for a reactive hook? It is if the parent is a call + * expression with a reactive hook callee and this node is a function expression + * and the first argument. + */ +function isReactiveHookCallback(node, options) { + return ( + (node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression') && + node.parent.type === 'CallExpression' && + isReactiveHook(node.parent.callee, options) && + node.parent.arguments[0] === node + ); +} + +export default { + meta: { + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + additionalHooks: { + type: 'string', + }, + }, + }, + ], + }, + create(context) { + // Parse the `additionalHooks` regex. + const additionalHooks = + context.options && + context.options[0] && + context.options[0].additionalHooks + ? new RegExp(context.options[0].additionalHooks) + : undefined; + const options = {additionalHooks}; + + return { + FunctionExpression: visitFunctionExpression, + ArrowFunctionExpression: visitFunctionExpression, + }; + + /** + * Visitor for both function expressions and arrow function expressions. + */ + function visitFunctionExpression(node) { + // We only want to lint nodes which are reactive hook callbacks. + if (!isReactiveHookCallback(node, options)) { + return; + } + + // Get the reactive hook node. + const reactiveHook = node.parent.callee; + + // Get the declared dependencies for this reactive hook. If there is no + // second argument then the reactive callback will re-run on every render. + // So no need to check for dependency inclusion. + const declaredDependenciesNode = node.parent.arguments[1]; + if (!declaredDependenciesNode) { + return; + } + + // Get the current scope. + const scope = context.getScope(); + + // Find all our "pure scopes". On every re-render of a component these + // pure scopes may have changes to the variables declared within. So all + // variables used in our reactive hook callback but declared in a pure + // scope need to be listed as dependencies of our reactive hook callback. + // + // According to the rules of React you can't read a mutable value in pure + // scope. We can't enforce this in a lint so we trust that all variables + // declared outside of pure scope are indeed frozen. + const pureScopes = new Set(); + { + let currentScope = scope.upper; + while (currentScope) { + pureScopes.add(currentScope); + if (currentScope.type === 'function') { + break; + } + currentScope = currentScope.upper; + } + // If there is no parent function scope then there are no pure scopes. + // The ones we've collected so far are incorrect. So don't continue with + // the lint. + if (!currentScope) { + return; + } + } + + // Get dependencies from all our resolved references in pure scopes. + const dependencies = new Map(); + gatherDependenciesRecursively(scope); + + function gatherDependenciesRecursively(currentScope) { + for (const reference of currentScope.references) { + // If this reference is not resolved or it is not declared in a pure + // scope then we don't care about this reference. + if (!reference.resolved) { + continue; + } + if (!pureScopes.has(reference.resolved.scope)) { + continue; + } + // Narrow the scope of a dependency if it is, say, a member expression. + // Then normalize the narrowed dependency. + + const referenceNode = fastFindReferenceWithParent( + node, + reference.identifier, + ); + const dependencyNode = getDependency(referenceNode); + const dependency = normalizeDependencyNode(dependencyNode); + // Add the dependency to a map so we can make sure it is referenced + // again in our dependencies array. + let nodes = dependencies.get(dependency); + if (!nodes) { + dependencies.set(dependency, (nodes = [])); + } + nodes.push(dependencyNode); + } + for (const childScope of currentScope.childScopes) { + gatherDependenciesRecursively(childScope); + } + } + + // Get all of the declared dependencies and put them in a set. We will + // compare this to the dependencies we found in the callback later. + const declaredDependencies = new Map(); + if (declaredDependenciesNode.type !== 'ArrayExpression') { + // If the declared dependencies are not an array expression then we + // can't verify that the user provided the correct dependencies. Tell + // the user this in an error. + context.report( + declaredDependenciesNode, + `React Hook ${context.getSource(reactiveHook)} has a second ` + + "argument which is not an array literal. This means we can't " + + "statically verify whether you've passed the correct dependencies.", + ); + } else { + for (const declaredDependencyNode of declaredDependenciesNode.elements) { + // Skip elided elements. + if (declaredDependencyNode === null) { + continue; + } + // If we see a spread element then add a special warning. + if (declaredDependencyNode.type === 'SpreadElement') { + context.report( + declaredDependencyNode, + `React Hook ${context.getSource(reactiveHook)} has a spread ` + + "element in its dependency list. This means we can't " + + "statically verify whether you've passed the " + + 'correct dependencies.', + ); + continue; + } + // Try to normalize the declared dependency. If we can't then an error + // will be thrown. We will catch that error and report an error. + let declaredDependency; + try { + declaredDependency = normalizeDependencyNode( + declaredDependencyNode, + ); + } catch (error) { + if (/Unexpected node type/.test(error.message)) { + context.report( + declaredDependencyNode, + 'Unsupported expression in React Hook ' + + `${context.getSource(reactiveHook)}'s dependency list. ` + + 'Currently only simple variables are supported.', + ); + continue; + } else { + throw error; + } + } + // If the programmer already declared this dependency then report a + // duplicate dependency error. + if (declaredDependencies.has(declaredDependency)) { + context.report( + declaredDependencyNode, + 'Duplicate value in React Hook ' + + `${context.getSource(reactiveHook)}'s dependency list for ` + + `"${context.getSource(declaredDependencyNode)}".`, + ); + } else { + // Add the dependency to our declared dependency map. + declaredDependencies.set( + declaredDependency, + declaredDependencyNode, + ); + } + } + } + + let usedDependencies = new Set(); + + // Loop through all our dependencies to make sure they have been declared. + // If the dependency has not been declared we need to report some errors. + for (const [dependency, dependencyNodes] of dependencies) { + let isDeclared = false; + + // If we can't find `foo.bar.baz`, search for `foo.bar`, then for `foo`. + let candidate = dependency; + while (candidate) { + if (declaredDependencies.has(candidate)) { + // Yay! Our dependency has been declared. + // Record this so we don't report is unused. + isDeclared = true; + usedDependencies.add(candidate); + break; + } + const lastDotAccessIndex = candidate.lastIndexOf('.'); + if (lastDotAccessIndex === -1) { + break; + } + // If we didn't find `foo.bar.baz`, try `foo.bar`. Then try `foo`. + candidate = candidate.substring(0, lastDotAccessIndex); + // This is not super solid but works for simple cases we support. + // Alternatively we could stringify later and use nodes directly. + } + + if (!isDeclared) { + // Oh no! Our dependency was not declared. So report an error for all + // of the nodes which we expected to see an error from. + for (const dependencyNode of dependencyNodes) { + context.report( + dependencyNode, + `React Hook ${context.getSource(reactiveHook)} references ` + + `"${context.getSource(dependencyNode)}", but it was not ` + + 'listed in the hook dependencies argument. This means if ' + + `"${context.getSource(dependencyNode)}" changes then ` + + `${context.getSource(reactiveHook)} won't be able to update.`, + ); + } + } + } + + // Loop through all the unused declared dependencies and report a warning + // so the programmer removes the unused dependency from their list. + for (const [dependency, dependencyNode] of declaredDependencies) { + if (usedDependencies.has(dependency)) { + continue; + } + + context.report( + dependencyNode, + `React Hook ${context.getSource(reactiveHook)} has an extra ` + + `dependency "${context.getSource(dependencyNode)}" which ` + + 'is not used in its callback. Removing this dependency may mean ' + + 'the hook needs to execute fewer times.', + ); + } + } + }, +}; + +/** + * Gets a dependency for our reactive callback from an identifier. If the + * identifier is the object part of a member expression then we use the entire + * member expression as a dependency. + * + * For instance, if we get `props` in `props.foo` then our dependency should be + * the full member expression. + */ +function getDependency(node) { + if ( + node.parent.type === 'MemberExpression' && + node.parent.object === node && + !node.parent.computed + ) { + return node.parent; + } else { + return node; + } +} + +/** + * Normalizes a dependency into a standard string representation which can + * easily be compared. + * + * Throws an error if the node type is not a valid dependency. + */ +function normalizeDependencyNode(node) { + if (node.type === 'Identifier') { + return node.name; + } else if (node.type === 'MemberExpression' && !node.computed) { + const object = normalizeDependencyNode(node.object); + const property = normalizeDependencyNode(node.property); + return `${object}.${property}`; + } else { + throw new Error(`Unexpected node type: ${node.type}`); + } +} + +/** + * ESLint won't assign node.parent to references from context.getScope() + * + * So instead we search for the node from an ancestor assigning node.parent + * as we go. This mutates the AST. + * + * This traversal is: + * - optimized by only searching nodes with a range surrounding our target node + * - agnostic to AST node types, it looks for `{ type: string, ... }` + */ +function fastFindReferenceWithParent(start, target) { + let queue = [start]; + let item = null; + + while (queue.length) { + item = queue.shift(); + + if (isSameIdentifier(item, target)) { + return item; + } + + if (!isAncestorNodeOf(item, target)) { + continue; + } + + for (let [key, value] of Object.entries(item)) { + if (key === 'parent') { + continue; + } + if (isNodeLike(value)) { + value.parent = item; + queue.push(value); + } else if (Array.isArray(value)) { + value.forEach(val => { + if (isNodeLike(val)) { + val.parent = item; + queue.push(val); + } + }); + } + } + } + + return null; +} + +function isNodeLike(val) { + return ( + typeof val === 'object' && + val !== null && + !Array.isArray(val) && + typeof val.type === 'string' + ); +} + +function isSameIdentifier(a, b) { + return ( + a.type === 'Identifier' && + a.name === b.name && + a.range[0] === b.range[0] && + a.range[1] === b.range[1] + ); +} + +function isAncestorNodeOf(a, b) { + return a.range[0] <= b.range[0] && a.range[1] >= b.range[1]; +} diff --git a/packages/eslint-plugin-react-hooks/src/index.js b/packages/eslint-plugin-react-hooks/src/index.js index a3d6216e097bc..f7e5c6fa2eb6d 100644 --- a/packages/eslint-plugin-react-hooks/src/index.js +++ b/packages/eslint-plugin-react-hooks/src/index.js @@ -8,7 +8,9 @@ 'use strict'; import RuleOfHooks from './RulesOfHooks'; +import ReactiveDeps from './ReactiveDeps'; export const rules = { 'rules-of-hooks': RuleOfHooks, + 'reactive-deps': ReactiveDeps, }; diff --git a/scripts/rollup/results.json b/scripts/rollup/results.json index 91d4f827bac0d..5b4047e47372b 100644 --- a/scripts/rollup/results.json +++ b/scripts/rollup/results.json @@ -928,8 +928,8 @@ "filename": "eslint-plugin-react-hooks.development.js", "bundleType": "NODE_DEV", "packageName": "eslint-plugin-react-hooks", - "size": 25592, - "gzip": 5885 + "size": 44512, + "gzip": 9733 }, { "filename": "eslint-plugin-react-hooks.production.min.js",