diff --git a/packages/plugins/eslint-plugin-react-core/src/rules/no-mixing-controlled-and-uncontrolled.spec.ts b/packages/plugins/eslint-plugin-react-core/src/rules/no-mixing-controlled-and-uncontrolled.spec.ts new file mode 100644 index 000000000..022786e6c --- /dev/null +++ b/packages/plugins/eslint-plugin-react-core/src/rules/no-mixing-controlled-and-uncontrolled.spec.ts @@ -0,0 +1,45 @@ +import { allValid, ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./no-mixing-controlled-and-uncontrolled"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: '', + errors: [ + { messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED" }, + ], + }, + { + code: '', + errors: [ + { messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED" }, + ], + }, + { + code: 'React.createElement("input", { checked: true, defaultChecked: true })', + errors: [ + { messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED" }, + ], + }, + { + code: 'React.createElement("input", { value: 1, defaultValue: 1 })', + errors: [ + { messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED" }, + ], + }, + ], + valid: [ + ...allValid, + '', + '', + '', + '', + '', + '', + "React.createElement('input')", + "React.createElement('input', { checked: true })", + "React.createElement('input', { checked: false })", + "React.createElement('input', { defaultChecked: true })", + "", + ], +}); diff --git a/packages/plugins/eslint-plugin-react-core/src/rules/no-mixing-controlled-and-uncontrolled.ts b/packages/plugins/eslint-plugin-react-core/src/rules/no-mixing-controlled-and-uncontrolled.ts new file mode 100644 index 000000000..af751361a --- /dev/null +++ b/packages/plugins/eslint-plugin-react-core/src/rules/no-mixing-controlled-and-uncontrolled.ts @@ -0,0 +1,80 @@ +import { NodeType } from "@eslint-react/ast"; +import { elementType, findPropInProperties, isCreateElementCall } from "@eslint-react/jsx"; +import { hasEveryProp } from "@eslint-react/jsx"; +import type { ESLintUtils } from "@typescript-eslint/utils"; +import { Option as O } from "effect"; +import type { ConstantCase } from "string-ts"; +import { isMatching } from "ts-pattern"; + +import { createRule } from "../../../eslint-plugin-react-dom/src/utils"; + +export const RULE_NAME = "no-mixing-controlled-and-uncontrolled"; + +export type MessageID = ConstantCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "disallow mixing controlled and uncontrolled .", + recommended: "recommended", + requiresTypeChecking: false, + }, + messages: { + NO_MIXING_CONTROLLED_AND_UNCONTROLLED: "Disallow controlled prop and uncontrolled prop being used together.", + }, + schema: [], + }, + name: RULE_NAME, + create(context) { + return { + CallExpression(node) { + if (!isCreateElementCall(node, context)) return; + const [name, props] = node.arguments; + if (!isMatching({ type: NodeType.Literal, value: "input" }, name)) return; + if (!props || props.type !== NodeType.ObjectExpression) return; + + const initialScope = context.sourceCode.getScope(node); + + if ( + O.isSome(findPropInProperties(props.properties, context, initialScope)("checked")) + && O.isSome(findPropInProperties(props.properties, context, initialScope)("defaultChecked")) + ) { + return context.report({ + messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED", + node: node, + }); + } + if ( + O.isSome(findPropInProperties(props.properties, context, initialScope)("value")) + && O.isSome(findPropInProperties(props.properties, context, initialScope)("defaultValue")) + ) { + return context.report({ + messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED", + node: node, + }); + } + }, + JSXOpeningElement(node) { + const name = elementType(node); + if (name !== "input") return; + + const initialScope = context.sourceCode.getScope(node); + if (hasEveryProp(node.attributes, ["checked", "defaultChecked"], context, initialScope)) { + return context.report({ + messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED", + node: node, + }); + } + + if (hasEveryProp(node.attributes, ["value", "defaultValue"], context, initialScope)) { + return context.report({ + messageId: "NO_MIXING_CONTROLLED_AND_UNCONTROLLED", + node: node, + }); + } + }, + }; + }, + defaultOptions: [], +}) satisfies ESLintUtils.RuleModule; diff --git a/website/pages/rules/_meta.ts b/website/pages/rules/_meta.ts index b6f73837b..a90bd4871 100644 --- a/website/pages/rules/_meta.ts +++ b/website/pages/rules/_meta.ts @@ -31,6 +31,7 @@ export default { "no-leaked-conditional-rendering": "no-leaked-conditional-rendering", "no-missing-component-display-name": "no-missing-component-display-name", "no-missing-key": "no-missing-key", + "no-mixing-controlled-and-uncontrolled": "no-mixing-controlled-and-uncontrolled", "no-nested-components": "no-nested-components", "no-redundant-should-component-update": "no-redundant-should-component-update", "no-set-state-in-component-did-mount": "no-set-state-in-component-did-mount", diff --git a/website/pages/rules/no-mixing-controlled-and-uncontrolled.mdx b/website/pages/rules/no-mixing-controlled-and-uncontrolled.mdx new file mode 100644 index 000000000..334fa6802 --- /dev/null +++ b/website/pages/rules/no-mixing-controlled-and-uncontrolled.mdx @@ -0,0 +1,45 @@ +# no-mixing-controlled-and-uncontrolled + +## Rule category + +Correctness. + +## What it does + +Prevents both `value` and `defaultValue` prop or both `checked` and `defaultChecked` prop on ``. + +## Why is this bad? + +A `` is either controlled or uncontrolled. Mixing controlled and uncontrolled props can lead to bugs and unexpected behavior. + +## Examples + +### Failing + +```tsx twoslash +import React from "react"; + +function Example() { + return ; + // ^^^^^^^^^^^^^^^^^^^^^^^^^ + // - Disallow controlled prop and uncontrolled prop being used together. +} +``` + +### Passing + +```tsx twoslash +import React from "react"; + +function Example1() { + return ; +} + +function Example2() { + return ; +} +``` + +## Further Reading + +- [react.dev: Controlled and uncontrolled components](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components)