From c3d3e25874cc8897e9700d403690c05122e81da7 Mon Sep 17 00:00:00 2001 From: Alexandr Metreniuc Date: Tue, 4 Oct 2022 23:07:32 +0300 Subject: [PATCH] [New] `hook-use-state`: add `allowDestructuredState` option --- CHANGELOG.md | 5 + docs/rules/hook-use-state.md | 19 +++ lib/rules/hook-use-state.js | 268 +++++++++++++++++------------- tests/lib/rules/hook-use-state.js | 53 ++++++ 4 files changed, 229 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7545fa2e9..bc5c6bc55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +### Added +* [`hook-use-state`]: add `allowDestructuredState` option ([#3449][] @ljharb) + +[#3449]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3449 + ## [7.31.9] - 2022.10.09 ### Fixed diff --git a/docs/rules/hook-use-state.md b/docs/rules/hook-use-state.md index c71eed4382..43970de357 100644 --- a/docs/rules/hook-use-state.md +++ b/docs/rules/hook-use-state.md @@ -48,3 +48,22 @@ export default function useColor() { return React.useState(); } ``` + +## Rule Options + +```js +... +"react/hook-use-state": [, { "allowDestructuredState": }] +... +``` + +### `allowDestructuredState` + +When `true` the rule will ignore the name of the destructured value. + +Examples of **correct** code for this rule, when configured with `{ "allowDestructuredState": true }`: + +```jsx +import React from 'react'; +const [{foo, bar, baz}, setFooBarBaz] = React.useState({foo: "bbb", bar: "aaa", baz: "qqq"}) +``` diff --git a/lib/rules/hook-use-state.js b/lib/rules/hook-use-state.js index 2549972121..a9d3b7961c 100644 --- a/lib/rules/hook-use-state.js +++ b/lib/rules/hook-use-state.js @@ -13,8 +13,13 @@ const report = require('../util/report'); // Rule Definition // ------------------------------------------------------------------------------ +function isNodeDestructuring(node) { + return node && (node.type === 'ArrayPattern' || node.type === 'ObjectPattern'); +} + const messages = { useStateErrorMessage: 'useState call is not destructured into value + setter pair', + useStateErrorMessageOrAddOption: 'useState call is not destructured into value + setter pair (you can allow destructuring by enabling "allowDestructuredState" option)', }; module.exports = { @@ -26,135 +31,166 @@ module.exports = { url: docsUrl('hook-use-state'), }, messages, - schema: [], + schema: [{ + type: 'object', + properties: { + allowDestructuredState: { + default: false, + type: 'boolean', + }, + }, + additionalProperties: false, + }], type: 'suggestion', hasSuggestions: true, }, - create: Components.detect((context, components, util) => ({ - CallExpression(node) { - const isImmediateReturn = node.parent - && node.parent.type === 'ReturnStatement'; - - if (isImmediateReturn || !util.isReactHookCall(node, ['useState'])) { - return; - } - - const isDestructuringDeclarator = node.parent - && node.parent.type === 'VariableDeclarator' - && node.parent.id.type === 'ArrayPattern'; - - if (!isDestructuringDeclarator) { - report( - context, - messages.useStateErrorMessage, - 'useStateErrorMessage', - { node } - ); - return; - } - - const variableNodes = node.parent.id.elements; - const valueVariable = variableNodes[0]; - const setterVariable = variableNodes[1]; - - const valueVariableName = valueVariable - ? valueVariable.name - : undefined; - - const setterVariableName = setterVariable - ? setterVariable.name - : undefined; - - const caseCandidateMatch = valueVariableName ? valueVariableName.match(/(^[a-z]+)(.*)/) : undefined; - const upperCaseCandidatePrefix = caseCandidateMatch ? caseCandidateMatch[1] : undefined; - const caseCandidateSuffix = caseCandidateMatch ? caseCandidateMatch[2] : undefined; - const expectedSetterVariableNames = upperCaseCandidatePrefix ? [ - `set${upperCaseCandidatePrefix.charAt(0).toUpperCase()}${upperCaseCandidatePrefix.slice(1)}${caseCandidateSuffix}`, - `set${upperCaseCandidatePrefix.toUpperCase()}${caseCandidateSuffix}`, - ] : []; - - const isSymmetricGetterSetterPair = valueVariable - && setterVariable - && expectedSetterVariableNames.indexOf(setterVariableName) !== -1 - && variableNodes.length === 2; - - if (!isSymmetricGetterSetterPair) { - const suggestions = [ - { - desc: 'Destructure useState call into value + setter pair', - fix: (fixer) => { - if (expectedSetterVariableNames.length === 0) { - return; - } + create: Components.detect((context, components, util) => { + const configuration = context.options[0] || {}; + const allowDestructuredState = configuration.allowDestructuredState || false; - const fix = fixer.replaceTextRange( - node.parent.id.range, - `[${valueVariableName}, ${expectedSetterVariableNames[0]}]` - ); + return { + CallExpression(node) { + const isImmediateReturn = node.parent + && node.parent.type === 'ReturnStatement'; - return fix; - }, - }, - ]; + if (isImmediateReturn || !util.isReactHookCall(node, ['useState'])) { + return; + } - const defaultReactImports = components.getDefaultReactImports(); - const defaultReactImportSpecifier = defaultReactImports - ? defaultReactImports[0] - : undefined; + const isDestructuringDeclarator = node.parent + && node.parent.type === 'VariableDeclarator' + && node.parent.id.type === 'ArrayPattern'; + + if (!isDestructuringDeclarator) { + report( + context, + messages.useStateErrorMessage, + 'useStateErrorMessage', + { node } + ); + return; + } + + const variableNodes = node.parent.id.elements; + const valueVariable = variableNodes[0]; + const setterVariable = variableNodes[1]; + const isOnlyValueDestructuring = isNodeDestructuring(valueVariable) && !isNodeDestructuring(setterVariable); + + if (allowDestructuredState && isOnlyValueDestructuring) { + return; + } - const defaultReactImportName = defaultReactImportSpecifier - ? defaultReactImportSpecifier.local.name + const valueVariableName = valueVariable + ? valueVariable.name : undefined; - const namedReactImports = components.getNamedReactImports(); - const useStateReactImportSpecifier = namedReactImports - ? namedReactImports.find((specifier) => specifier.imported.name === 'useState') + const setterVariableName = setterVariable + ? setterVariable.name : undefined; - const isSingleGetter = valueVariable && variableNodes.length === 1; - const isUseStateCalledWithSingleArgument = node.arguments.length === 1; - if (isSingleGetter && isUseStateCalledWithSingleArgument) { - const useMemoReactImportSpecifier = namedReactImports - && namedReactImports.find((specifier) => specifier.imported.name === 'useMemo'); - - let useMemoCode; - if (useMemoReactImportSpecifier) { - useMemoCode = useMemoReactImportSpecifier.local.name; - } else if (defaultReactImportName) { - useMemoCode = `${defaultReactImportName}.useMemo`; - } else { - useMemoCode = 'useMemo'; + const caseCandidateMatch = valueVariableName ? valueVariableName.match(/(^[a-z]+)(.*)/) : undefined; + const upperCaseCandidatePrefix = caseCandidateMatch ? caseCandidateMatch[1] : undefined; + const caseCandidateSuffix = caseCandidateMatch ? caseCandidateMatch[2] : undefined; + const expectedSetterVariableNames = upperCaseCandidatePrefix ? [ + `set${upperCaseCandidatePrefix.charAt(0).toUpperCase()}${upperCaseCandidatePrefix.slice(1)}${caseCandidateSuffix}`, + `set${upperCaseCandidatePrefix.toUpperCase()}${caseCandidateSuffix}`, + ] : []; + + const isSymmetricGetterSetterPair = valueVariable + && setterVariable + && expectedSetterVariableNames.indexOf(setterVariableName) !== -1 + && variableNodes.length === 2; + + if (!isSymmetricGetterSetterPair) { + const suggestions = [ + { + desc: 'Destructure useState call into value + setter pair', + fix: (fixer) => { + if (expectedSetterVariableNames.length === 0) { + return; + } + + const fix = fixer.replaceTextRange( + node.parent.id.range, + `[${valueVariableName}, ${expectedSetterVariableNames[0]}]` + ); + + return fix; + }, + }, + ]; + + const defaultReactImports = components.getDefaultReactImports(); + const defaultReactImportSpecifier = defaultReactImports + ? defaultReactImports[0] + : undefined; + + const defaultReactImportName = defaultReactImportSpecifier + ? defaultReactImportSpecifier.local.name + : undefined; + + const namedReactImports = components.getNamedReactImports(); + const useStateReactImportSpecifier = namedReactImports + ? namedReactImports.find((specifier) => specifier.imported.name === 'useState') + : undefined; + + const isSingleGetter = valueVariable && variableNodes.length === 1; + const isUseStateCalledWithSingleArgument = node.arguments.length === 1; + if (isSingleGetter && isUseStateCalledWithSingleArgument) { + const useMemoReactImportSpecifier = namedReactImports + && namedReactImports.find((specifier) => specifier.imported.name === 'useMemo'); + + let useMemoCode; + if (useMemoReactImportSpecifier) { + useMemoCode = useMemoReactImportSpecifier.local.name; + } else if (defaultReactImportName) { + useMemoCode = `${defaultReactImportName}.useMemo`; + } else { + useMemoCode = 'useMemo'; + } + + suggestions.unshift({ + desc: 'Replace useState call with useMemo', + fix: (fixer) => [ + // Add useMemo import, if necessary + useStateReactImportSpecifier + && (!useMemoReactImportSpecifier || defaultReactImportName) + && fixer.insertTextAfter(useStateReactImportSpecifier, ', useMemo'), + // Convert single-value destructure to simple assignment + fixer.replaceTextRange(node.parent.id.range, valueVariableName), + // Convert useState call to useMemo + arrow function + dependency array + fixer.replaceTextRange( + node.range, + `${useMemoCode}(() => ${context.getSourceCode().getText(node.arguments[0])}, [])` + ), + ].filter(Boolean), + }); } - suggestions.unshift({ - desc: 'Replace useState call with useMemo', - fix: (fixer) => [ - // Add useMemo import, if necessary - useStateReactImportSpecifier - && (!useMemoReactImportSpecifier || defaultReactImportName) - && fixer.insertTextAfter(useStateReactImportSpecifier, ', useMemo'), - // Convert single-value destructure to simple assignment - fixer.replaceTextRange(node.parent.id.range, valueVariableName), - // Convert useState call to useMemo + arrow function + dependency array - fixer.replaceTextRange( - node.range, - `${useMemoCode}(() => ${context.getSourceCode().getText(node.arguments[0])}, [])` - ), - ].filter(Boolean), - }); - } - - report( - context, - messages.useStateErrorMessage, - 'useStateErrorMessage', - { - node: node.parent.id, - suggest: suggestions, + if (isOnlyValueDestructuring) { + report( + context, + messages.useStateErrorMessageOrAddOption, + 'useStateErrorMessageOrAddOption', + { + node: node.parent.id, + } + ); + return; } - ); - } - }, - })), + + report( + context, + messages.useStateErrorMessage, + 'useStateErrorMessage', + { + node: node.parent.id, + suggest: suggestions, + } + ); + } + }, + }; + }), }; diff --git a/tests/lib/rules/hook-use-state.js b/tests/lib/rules/hook-use-state.js index 9f127f2a8e..1cadaf8a1a 100644 --- a/tests/lib/rules/hook-use-state.js +++ b/tests/lib/rules/hook-use-state.js @@ -177,6 +177,22 @@ const tests = { `, features: ['ts'], }, + { + code: ` + import { useState } from 'react'; + + const [{foo, bar, baz}, setFooBarBaz] = useState({foo: "bbb", bar: "aaa", baz: "qqq"}) + `, + options: [{ allowDestructuredState: true }], + }, + { + code: ` + import { useState } from 'react'; + + const [[index, value], setValueWithIndex] = useState([0, "hello"]) + `, + options: [{ allowDestructuredState: true }], + }, ]), invalid: parsers.all([ { @@ -498,6 +514,43 @@ const tests = { }, ], }, + { + code: ` + import { useState } from 'react'; + + const [{foo, bar, baz}, setFooBarBaz] = useState({foo: "bbb", bar: "aaa", baz: "qqq"}) + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair (you can allow destructuring by enabling "allowDestructuredState" option)', + }, + ], + }, + { + code: ` + import { useState } from 'react'; + + const [[index, value], setValueWithIndex] = useState([0, "hello"]) + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair (you can allow destructuring by enabling "allowDestructuredState" option)', + }, + ], + }, + { + code: ` + import { useState } from 'react'; + + const [{foo, bar, baz}, {setFooBarBaz}] = useState({foo: "bbb", bar: "aaa", baz: "qqq"}) + `, + options: [{ allowDestructuredState: true }], + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + }, + ], + }, { code: ` import { useState } from 'react'